Writing code
State
@uniflowed/state is atoms: a value declared at module scope, read and written
from anywhere, with no provider to mount. If you know Jotai you know the shape
— and four things are deliberately different, starting with the fact that
there are four constructors instead of one.
Four constructors, not one overload
Jotai spells every kind of atom atom(...) and works out which was meant from
the arguments. That resolves at the call site in TypeScript; in Flow the honest
version is an intersection of function types the checker handles poorly, and it
is ambiguous at run time anyway — atom(f) cannot tell a derived atom from a
primitive one holding a function.
So the four kinds have four names:
import { action, atom, selector, writableSelector } from "@uniflowed/state";
const count = atom(1);
const doubled = selector<number>((get) => get(count) * 2);
const bounded = writableSelector<number, number>(
(get) => get(count),
(get, set, next) => set(count, Math.max(0, next)),
);
const increment = action<number>((get, set, by) => set(count, get(count) + by));
In Jotai's terms those are atom(value), atom(read), atom(read, write) and
atom(null, write). Each returns exactly one type here, so what a binding can
do is in its type: selector gives a ReadonlyAtom, and writing to one is both
a type error and a run-time error naming the atom.
Nothing above allocates. An atom is a description; a store is what holds values, and an atom that no store has touched costs nothing.
Without React
import { createStore, read, subscribe, write } from "@uniflowed/state";
const store = createStore();
const stop = subscribe(count, () => console.log(read(count, store)), store);
write(count, 1, store);
write(count, 2, store);
stop();
read, write and subscribe take an optional store and use a lazily created
default one when you leave it out — which is why the constructors above are
already usable in a test, a loader or a script. getDefaultStore() names it if
you need it.
A store is deliberately small: get, set and sub are all an application
needs, and everything else in this package is built from them.
In React
"use client";
// @flow
import { useAtomValue, useSetAtom } from "@uniflowed/state";
component Controls() {
const bump = useSetAtom(count);
return (
<button type="button" onClick={() => bump((n) => n + 1)}>
add
</button>
);
}
component Total() {
const shown = useAtomValue(doubled);
return <output>{shown}</output>;
}
useAtomValue subscribes, useSetAtom does not — a component that only writes
never re-renders when the value changes — and useAtom is the pair, for when
you want both. The setter's identity is cached per store and atom, so it is
stable across every component that asks for it, not merely within one.
The subscription is useSyncExternalStore, and that is a rule rather than a
detail. Nothing here mutates anything a render can observe, no hook hands back a
live mutable object, and the snapshot a render reads is taken without becoming a
dependency of whatever else was computing. The server snapshot is the same
function as the client's, so hydration compares like with like.
Provider gives a subtree its own store, which is what a server rendering per
request needs — module-scope state is per-process, and one visitor's store must
not be another's. A tree with no provider gets the default store rather than an
error.
Derived, composed, and the escape hatches
A family is atoms by key, memoised:
const byId = atomFamily((id: string) => atom(`article ${id}`));
byId("a") === byId("a"); // the same atom
byId.remove("a"); // and it is gone
byId.size(); // 0
A default that can be taken back:
const override = atomWithDefault<number>((get) => get(base));
write(override, 99); // now 99
write(override, RESET); // back to following `base`
RESET is an opaque symbol rather than a string or undefined, so an atom
whose values are strings cannot accidentally hold the one that means "reset".
Persistence is in the write, not in a subscription, so it does not depend on the atom being mounted anywhere:
const theme = atomWithStorage("theme", "light", storage);
The third argument is a { getItem, setItem, removeItem } adapter. Leave it out
and you get a plain atom — nothing reaches for localStorage on its own, which
is what makes the same module safe on a server. Stored data that cannot be read
falls back to the initial value rather than throwing.
The small ones
Four constructors and two hooks that are each a few lines over what is above. They are here because they are the names a Jotai user reaches for, and because writing them in the package is how the claim that the four constructors are enough stays honest — none of them needed anything the public API does not already offer.
selectAtom is one part of an atom, so that a reader of the part is woken
only when the part changes:
const name = selectAtom(user, (current) => current.name);
A component reading name re-renders when the name changes and not when the
avatar does. A selection that builds a fresh value each time — an array of ids,
a filtered list — takes a third argument saying what "unchanged" means for it:
const ids = selectAtom(
rows,
(all) => all.map((row) => row.id),
(previous, next) => previous.length === next.length && previous.every((id, at) => id === next[at]),
);
Jotai's selector is also handed the previous slice. This one is not: a read that
can see its own output is not a pure function of its dependencies, which is what
the equality cutoff underneath rests on — and equals says the thing that
parameter was used to say.
atomWithReducer is useReducer as an atom, and the point is the type: the
value and the argument are different, so useSetAtom hands a component a
dispatch that takes actions rather than states.
const count = atomWithReducer<number, "increment" | "clear">(0, (current, action) =>
action === "increment" ? current + 1 : 0,
);
atomWithReset is a piece of state that also answers to RESET. atom(0)
cannot — its argument type is a value or a reducer and nothing else — so this is
what "back to the default" is written with:
const theme = atomWithReset("light");
write(theme, "dark");
write(theme, RESET); // "light"
freezeAtom deep-freezes a value so that changing it in place fails where
it happens:
const guarded = freezeAtom(settings);
read(guarded).tags.push("b"); // throws, in a module
That mutation is the most expensive bug in this style of state, because the symptom — nothing re-rendered — appears in a component that is not the one at fault. Note that there is one object and not two: the atom this derives from is frozen along with it, which is what makes the guard worth having and is worth knowing before wrapping an atom other code writes to.
useResetAtom is the () => void that goes straight onto an onClick,
where useSetAtom would need a wrapper to supply the symbol. It takes an atom
whose write accepts RESET, so a plain atom is refused at the call.
useAtomCallback is a handler that can read and write any atom and
subscribes to none of them:
component Submit() {
const submit = useAtomCallback((get, set) => send(get(user)));
return (
<button type="button" onClick={submit}>
Send
</button>
);
}
useAtomValue(user) would re-render this component every time the user
changed, for a value it only ever looks at once.
Async atoms are values, not suspense
This is the difference that will catch a Jotai reader out.
const user = asyncAtom<string>(async (get) => fetchUser(get(userId)));
user's value is a Loadable — { state: "loading" },
{ state: "hasData", data } or { state: "hasError", error } — and a component
renders it:
component Name() {
const loadable = useAtomValue(user);
return match (loadable.state) {
"loading" => <p>…</p>,
"hasError" => <p>could not load</p>,
"hasData" => <p>{loadable.data}</p>,
};
}
Jotai's async atom suspends. This one cannot, and says why rather than pretending
the choice was aesthetic: Suspense is not available to a useSyncExternalStore
reader without throwing a promise from inside a snapshot, which is neither
supported nor safe under concurrent rendering. So the loading state is a value
you render.
unwrap(user, fallback) is the shortcut when you do not want the three cases:
const name = unwrap(user, "…");
Asking again
An asyncAtom otherwise reloads only when something it read changes, and
writing a dependency the value it already holds is dropped by the equality
cutoff — correctly, and it leaves "fetch that again" with no expression at all.
That is the ordinary case after a mutation, and it is the Retry button on the
error state Loadable exists to make renderable:
import { refresh } from "@uniflowed/state";
await save(draft);
refresh(user); // or refresh(user, store)
The atom passes through { state: "loading" } on the way, so a list that shows
a spinner while it refetches gets one without asking. refresh is a free
function rather than a write, unlike Jotai's atomWithRefresh: WritableAtom<T, A> promises that A is the argument type, and an atom that is suddenly
writable with no argument muddies that — and a free function works from a route
handler and a test, neither of which has a component to hold a setter.
Only an asyncAtom can be refreshed, and Flow enforces it: refresh takes the
AsyncAtom<T> that asyncAtom returns, so a selector that happens to produce
a Loadable is rejected at the call site rather than at run time.
What a write does
Every write is batched. An action that sets three atoms wakes each subscriber
once, not three times:
const bump = action<void>((get, set) => {
set(first, get(first) + 1);
set(second, get(second) + 1);
});
A subscriber of a selector over both is called once. And get inside a write
is untracked — a write is not a computation, and an atom that recomputed because
a handler happened to look at something would be very hard to explain.
Persisted state
atomWithStorage mirrors an atom into a key-value store:
import { RESET, atomWithStorage, createJSONStorage } from "@uniflowed/state";
const theme = atomWithStorage("theme", "light", createJSONStorage(() => localStorage));
The thunk is what makes that line safe in a file a server imports, and the
reason is more specific than "the storage might be missing": on a runtime
without Web Storage the identifier localStorage is not defined, so evaluating
it throws a ReferenceError rather than producing undefined. It is called
inside a try every time, so a module naming a storage the runtime does not
have still imports, and every operation on it becomes a no-op returning the
initial value. Node has Web Storage from 22 and only behind a flag, Deno has it
for a page it can name an origin for, Bun has it since 1.2, and an edge runtime
has neither it nor a window. A browser with site data blocked throws on the
property itself, and Safari in a private window throws on a write once its
quota is reached — both degrade to an atom that is simply not persisted.
The stored value is read on mount
This is the part that decides whether a server-rendered page hydrates. The
server has no storage, so it renders initial. If the browser reads
localStorage while the module is being evaluated, the first client render is
the stored value against markup that says initial — a hydration mismatch that
nothing in the React binding can undo, because the value was already wrong
before a hook was called.
So the read happens in the atom's onMount, which React runs after commit. The
first client render is initial, exactly like the server's; the stored value
arrives on the render immediately after. It is also per store, so two requests
rendering concurrently in one process do not share one read.
The cost is worth naming: an atom nothing has mounted reads initial even when
storage holds something else, so a route handler that only reads sees the
default. { getOnInit: true } is for that caller and for the application that
has no server render to agree with — it moves the read to first use rather than
to mount, which is still per store and still never at import.
RESET removes the key
write(theme, RESET);
Setting an atom back to initial stores initial; RESET removes the key, so
"clear my preferences" leaves nothing behind for the next schema change to find.
Other storages
A StorageAdapter<T> is typed in the value, not in strings: a cookie jar, a
React Native AsyncStorage and an in-memory map in a test do not agree on a
serialisation. createJSONStorage is the one that does, over anything with
getItem, setItem and removeItem.
Its optional subscribe is how another tab reaches this one — createJSONStorage
wires it to the browser's storage event, and an outside write updates every
mounted store and no unmounted one. It deliberately does not announce writes
made in this process: two stores in one process are two stores, and keeping them
in step would undo the isolation they exist for.
Reading storage is also the one unchecked step in the package. JSON.parse
answers any, and what comes back was written by an older version of the
application, by another tab, or by someone editing their own localStorage.
The default trusts it, because persistence is a cache and a cache that refuses
to start is worse than one that is occasionally stale. The revive option is
how a caller who minds closes it:
createJSONStorage(() => localStorage, { revive: (raw) => themeSchema.parse(raw) });
A revive that throws is a value that will not parse, which falls back to
initial like any other unreadable key.
Storage that has to be awaited
IndexedDB, a React Native AsyncStorage, a preference store behind a request:
none of them can answer a read on the spot, and Jotai's atomWithStorage copes
by making the atom's value T | Promise<T> and leaning on Suspense. Without
Suspense the honest shape is an atom whose value is a Loadable<T> — a
different value type, and therefore a different constructor rather than an
option on the one above:
import { atomWithAsyncStorage } from "@uniflowed/state";
const draft = atomWithAsyncStorage("draft", "", indexedDb);
// { state: "loading" }, and then { state: "hasData", data: "…" }
The adapter is the same { getItem, setItem, removeItem } with the read made a
promise; getItem is also handed the load's AbortSignal, so an adapter that
can stop early has what it needs to. createJSONStorage stays synchronous — it
is defined over the Web Storage shape, and there is no asynchronous storage it
would fit.
Three things behave differently from the synchronous version, and each is a decision rather than a consequence:
- A write while the first read is in flight wins, and the read is abandoned
— signal aborted, result never adopted. Nothing here counts generations to
achieve that: the write takes the load out of the atom's dependencies, and
@uniflowed/celldoes the rest, exactly asatomWithDefaultstops depending on its default. - A read that fails is
{ state: "hasError" }, not a fallback. That is the whole reason the second constructor can exist: "the database is locked" is not "nobody has set a preference", and the synchronous version has nowhere to say so. An absent key is still the initial value. - A write that fails is silent. The value the caller wrote is the atom's value whatever storage did with it; it simply will not outlive the session.
RESET removes the key and puts the atom back to its initial value without
reading again — a re-read would race a removal that has not finished and could
answer with what it had just deleted. And the setter's reducer form is handed
the Loadable, not the value: a write can happen before the first read has
settled, so (current) => current + 1 would be a promise the atom cannot keep.
One habit does not carry over: the read starts as soon as a store is asked for
the atom, mounted or not. atomWithStorage waits for a mount because the first
client render has to be the one the server already sent; here both sides render
loading, so there is nothing to disagree with and nothing to wait for.
write(draft, "typed");
write(draft, (current) => (current.state === "hasData" ? `${current.data}!` : "!"));
write(draft, RESET);
The layer below
Under the atoms is @uniflowed/cell, the dependency graph they are
instantiated into. It has two constructors and no concept of a store:
import { derived, read, state, subscribe, write } from "@uniflowed/cell";
const count = state(0);
const doubled = derived(() => read(count) * 2);
subscribe(doubled, () => console.log(read(doubled)));
write(count, 21); // 42
A cell holds its own value, and that is the whole difference. An atom is a
definition that has a different value in every store; a cell is the value.
So read, write and subscribe are spelled the same in both packages and
take different arguments, and useCell is the one hook here that does not take
a store. Reach for a cell when a value belongs to the process rather than to a
tree — a route loader hands out cells — and reach for an atom otherwise.
Why state and derived
They were cell and computed until
#521, and each named the
machine rather than the value. cell named the node the call allocates, at the
one place a reader is asking a different question — does this hold a value, or
follow from one? computed named something that had already happened, for a
value that is usually computed later and sometimes never. state and derived
are the two answers to that question.
Neither old spelling survives as a deprecated alias. @uniflowed/cell has
never been published, so the names have never been importable and an alias
would be a second name for one thing kept for nobody — the first release that
carries this package carries state and derived and nothing else. The
package keeps its own name: Cell<T> is what both constructors return, and
importing from it at all means working with the graph directly, which is the
moment "a node in a dependency graph" is the useful word.
Coming from Jotai
| Jotai | Here | Why |
|---|---|---|
atom(value | read | read+write) | atom, selector, writableSelector, action | Flow resolves an overload set worse than TypeScript, and atom(f) is ambiguous at run time |
| Async atoms suspend | Async atoms are Loadable values | A useSyncExternalStore reader cannot suspend safely |
| Its own dependency graph | @uniflowed/cell's | Two implementations of dependency tracking is one too many, and the copy is the one that rots |
atomWithStorage defaults to localStorage | No storage unless you pass an adapter | The same module has to be safe on a server |
atomWithStorage takes an async storage | atomWithAsyncStorage, whose value is a Loadable<T> | Different value type, so a different constructor rather than an option |
atomWithRefresh makes the atom writable | refresh(atom, store?), a free function | WritableAtom<T, A> promises A is the argument, and a refresh has none — and a free function works from a route handler |
atomFamily(create, areEqual?) | atomFamily(create) plus remove and size | Keys are compared by identity; a comparator invites a family keyed by a fresh object every render |
selectAtom(a, f, equalityFn) | selectAtom(a, f, equals?) | Same thing, minus the previous slice the selector was handed |
debugLabel, devtools | A label for diagnostics only | Never load-bearing |
Not implemented, each for a stated reason rather than for want of time:
| Jotai | Why not |
|---|---|
atomWithLazy | atomWithDefault already takes exactly that function. A second name for one thing is worse than one name for it |
useHydrateAtoms | It writes to shared state during render. createStore(), write(...), <Provider store> is one line longer and writes nothing during one |
atomFamily's areEqual | It turns an O(1) lookup into a scan of every key the family has seen, on a call that happens once per row per render. Make a tuple key a string instead |
loadable | Deprecated upstream in favour of unwrap, which is here — and asyncAtom produces the Loadable shape directly rather than needing a wrapper |
useReducerAtom | Deprecated upstream. atomWithReducer is the replacement, and it is here |
splitAtom | Not a stub and not a few lines: the per-row atoms have to be memoised somewhere, and a selector's read is handed a get and nothing else. Every version of it changes the store's model, which does not belong inside a utility |
atomWithObservable | The half that does not read other atoms is atom(initial, { onMount }). The half that does needs a mount that re-runs when a dependency changes, and onMount runs for the first subscriber and stops for the last — never in between |
There is one thing with no Jotai analogue: useCell reads a cell from
the layer below directly, with no store in the way.
Every snippet on this page was run before it was published — as parts of a
module, not as programs. Two fences stand on their own — the first, and the one
under The layer below, which is the only one that imports from another
package. The rest continue from the first, most leave their @uniflowed/state
import to it, and a few name something a real module would have declared —
base, storage, userId, fetchUser, first, second, user, rows,
settings, send, indexedDb — rather than repeating a setup that is not the
point of the example. tests/library/state.test.js is where the behaviour above
is pinned, with that setup written out — uf test#library state.test.js, and
cell.test.js for the layer below.