Concepts
Selector-first subscriptions
useSyncExternalStore (uSES) lets you subscribe to an external store and select exactly what your component needs:
const value = useStoreSelector(store, s => s.items[i].value);Only this component re-renders when items[i].value changes.
For UI components, always read state through
useSelector/useStoreSelector(or slice hooks built on them).store.getState()is synchronous but does not subscribe, so using it inside render will freeze the value and miss updates. ReservegetState()for controllers (viauseActions/bindActions) or utility code that needs an immediate snapshot outside of React rendering. Controller factories receiveapi.set,api.replace, andapi.reset, so they can orchestrate updates, full-state swaps, or return to the captured initial state without leaking into UI code.
Equality
By default, selectors use Object.is(prev, next). You can provide a custom comparator:
const x = useStoreSelector(store, s => derive(s), (a, b) => shallowEqual(a, b));Prefer deriving cheap, immutable values. Avoid deep equals on huge objects.
Context vs store-only
- Store-only (
makeStore + useStoreSelector) is perfect for isolated modules and feature-local state that can be shared. - Context (
createContextSync) gives ergonomic app-levelProvider+useSelector+bindActions.
Pure slice helpers
Use Tiny.createSlice(select, fns) if you want reusable, pure updaters bound to the root;
or build actions with Tiny.bindActions(api => ({ ... })) for side-effects and composition. Slice actions and api.set must return the next state synchronously—think useReducer, just without boilerplate.
Calling slices from controllers
Slice hooks can be consumed inside controller factories so both pieces share the same store instance:
const useTodos = Tiny.createSlice(s => s.todos, {
add(root, todo: Todo) {
return { ...root, todos: [todo, ...root.todos] };
},
confirm(root, id: string, real: Todo) {
return {
...root,
todos: root.todos.map(t => (t.id === id ? { ...real, optimistic: false } : t)),
};
},
});
const useTodosActions = Tiny.bindActions((_api) => {
const { actions: todos } = useTodos(); // safe: slice uses the same context store
return {
async addAndPersist(text: string) {
const temp = { id: `tmp-${Date.now()}`, text, optimistic: true };
todos.add(temp); // optimistic update (pure slice action)
try {
const res = await fetch("/api/todos", { method: "POST", body: JSON.stringify({ text }) });
const real = await res.json();
todos.confirm(temp.id, real);
} catch {
// failures can still use slice helpers
todos.confirm(temp.id, { ...temp, optimistic: false });
}
},
};
});This pattern keeps pure updates in slices while controllers handle side-effects. Slice actions remain pure and synchronous; controllers orchestrate async work without re-implementing state transitions.