| name | react-19-primitives |
| description | React 19 primitive APIs for snapmatch — `use(promise)`, `useTransition`, `useDeferredValue`, `useOptimistic`, `useActionState`, `useFormStatus`, and the `<Suspense>` boundary. Owns the API surface; idb owns the snapmatch root-hydration usage. Triggers on: use(promise), useTransition, useDeferredValue, useOptimistic, useActionState, useFormStatus, Suspense, react 19 hook. |
| license | MIT |
Sub-skill of react. Owns the React-19-only hooks and the <Suspense> API surface — what each one accepts, what it returns, what it suspends, and when to reach for it. The current snapmatch wiring of <Suspense> + use(idbHydrationPromise) at the app shell is owned by idb (which produces the promise and the Suspense pattern); this skill just describes the API the integration uses.
When to invoke
- Reading a value out of a promise inside render —
use(promise).
- Splitting an update into urgent + non-urgent parts —
useTransition, useDeferredValue.
- Showing an instant UI update before the real mutation lands —
useOptimistic.
- Wiring a form action and reading its pending/result state —
useActionState, useFormStatus.
- Authoring or moving a
<Suspense> boundary that isn't the snapmatch root one (which idb owns — it produces idbHydrationPromise and the Suspense wiring at the app shell).
Owns
React 19 primitives encouraged by the stack: use(promise), useTransition, useDeferredValue, useOptimistic, useActionState, useFormStatus, and <Suspense> boundaries.
Defers to
react (parent) — version pin and routing.
react-compiler-rules — for "no useMemo around a use(promise) read"; the Compiler memoizes already.
idb — for the Snapmatch root <Suspense> + use(idbHydrationPromise) integration. This skill
describes what use(promise) does; idb owns the promise, shell boundary, local-only debounced
write-through, post-commit BroadcastChannel invalidation, and durable replica/outbox boundary.
jotai — for the reactive projection that consumes hydrated state once
use(idbHydrationPromise) resolves, including optimistic command state published after IDB commit.
tanstack-router-routing — for <Suspense> boundaries inside route components / loaders.
Snapmatch stack rules
- Pillar 3 (remote-first, locally resilient state) keeps one root
<Suspense> +
use(idbHydrationPromise) contract. Committed shared state flows from Firestore through Zod and
revision reconciliation into the IDB replica/outbox, then into Jotai. Local commands commit to the
IDB outbox/checkpoint before optimistic Jotai publication and remote delivery. Components use atoms
or command APIs and never call Firebase directly. Production domain sync remains disabled; App
Check is not implemented.
- Pillar 4 (CLI-gate-first) means: a
use(promise) that throws an unhandled rejection bubbles to the nearest error boundary; in dev that surface counts as a gate failure of the same severity as a TS error. Wrap risky promises in error boundaries.
- React Compiler purity (see
react-compiler-rules) means: useTransition's start callback is fine in render path; useOptimistic's reducer is a pure function of (state, action) — same purity rule as Jotai derivations.
- Server components are not used (SPA-only).
useFormStatus works inside client <form> elements driven by an action prop; there is no RSC form integration in this repo.
Patterns
use(promise) — read a stable Promise inline
import { use, Suspense } from "react";
function Inner({ promise }: { promise: Promise<string> }) {
const value = use(promise);
return <span>{value}</span>;
}
export function Outer({ promise }: { promise: Promise<string> }) {
return (
<Suspense fallback={<span>Loading…</span>}>
<Inner promise={promise} />
</Suspense>
);
}
The Promise must be stable across renders (created at module scope, in a parent atom, or in a
parent's effect—never new Promise() in a child render). Snapmatch creates the IDB hydration Promise
once at module load and use()s it at the root shell. Remote listeners reconcile through the state
layer into IDB/Jotai; they do not create per-component Firestore promises.
useTransition — mark an update non-urgent
import { useTransition } from "react";
import { useSetAtom } from "jotai";
import { queryAtom } from "~/state/atoms";
export function SearchBox() {
const [pending, startTransition] = useTransition();
const setQuery = useSetAtom(queryAtom);
return (
<>
<input
onChange={(e) => startTransition(() => setQuery(e.target.value))}
aria-busy={pending}
/>
{pending ? <span role="status">Filtering…</span> : null}
</>
);
}
The input update is urgent (typing should be instant); the downstream filter recompute is non-urgent (React can interrupt it). pending is the right signal for an aria-busy attribute.
useDeferredValue — read a slightly-stale derived value
import { useDeferredValue } from "react";
import { useAtomValue } from "jotai";
import { queryAtom } from "~/state/atoms";
export function Results() {
const query = useAtomValue(queryAtom);
const deferredQuery = useDeferredValue(query);
return <FilterableList query={deferredQuery} />;
}
The fast surface (the input) reads query; the expensive surface (the list) reads deferredQuery. React renders the input first, then catches up the list when there's idle time.
useOptimistic — show the result before the write commits
import { useOptimistic } from "react";
import { useAtomValue, useSetAtom } from "jotai";
import { progressAtom } from "~/state/atoms";
export function CompleteButton() {
const progress = useAtomValue(progressAtom);
const setProgress = useSetAtom(progressAtom);
const [optimistic, setOptimistic] = useOptimistic(progress, (state, completed: boolean) => ({
...state,
completed,
}));
return (
<button
onClick={() => {
setOptimistic(true);
// Current generated sample: Jotai writes through to IDB (see `idb`).
setProgress((p) => ({ ...p, completed: true }));
}}
>
{optimistic.completed ? "Completed" : "Complete"}
</button>
);
}
The reducer (state, action) => newState is pure—the same discipline as Jotai derived atoms. On
commit, React reverts optimistic to the real value. For shared state, dispatch a validated command
through the Jotai/state boundary; that boundary durably commits the IDB outbox before publication and
owns Firestore acknowledgement/rejection choreography.
useActionState — bind a form action and read its result
import { useActionState } from "react";
async function submitScore(_prev: number | null, formData: FormData): Promise<number | null> {
const value = Number(formData.get("score"));
return Number.isFinite(value) ? value : null;
}
export function ScoreForm() {
const [score, formAction, pending] = useActionState(submitScore, null);
return (
<form action={formAction}>
<input name="score" inputMode="numeric" />
<button type="submit" disabled={pending}>Save</button>
{score !== null ? <output>Saved {score}</output> : null}
</form>
);
}
The action is a (prevState, formData) => Promise<nextState> reducer. pending is true for the duration of the action; score is the latest returned value.
useFormStatus — read the parent form's pending state from a child
import { useFormStatus } from "react-dom";
export function SubmitButton() {
const { pending } = useFormStatus();
return <button type="submit" disabled={pending}>{pending ? "Saving…" : "Save"}</button>;
}
useFormStatus reads from the nearest <form action={...}> ancestor — must be rendered inside the form, not in the same component that owns it. Lives in react-dom, not react.
<Suspense> — boundary for any use(promise) or async-atom read
import { Suspense } from "react";
export function Page() {
return (
<Suspense fallback={<Spinner />}>
<AsyncContent />
</Suspense>
);
}
For the Snapmatch root hydration boundary, see idb—there is exactly one at the app shell (with
fallback={null} because the prerendered HTML shell is the fallback). jotai consumes the resolved
IDB snapshot afterward; remote reconciliation enters React through IDB/Jotai rather than a route or
component-owned Firestore promise.
useId — stable IDs for ARIA wiring
import { useId } from "react";
export function Field() {
const id = useId();
return (
<>
<label htmlFor={id}>Name</label>
<input id={id} />
</>
);
}
Stable across renders, unique per call site. Useful for aria-labelledby, aria-describedby, htmlFor.
Anti-patterns
- Don't pass a freshly-allocated Promise into
use() from a child render — use(fetch(url)) creates a new Promise every render and loops. Allocate the Promise at module scope, in a parent atom, or in a parent's effect, and pass it down.
- Don't add a per-component
<Suspense> for IDB hydration — the root <Suspense> + use(idbHydrationPromise) runs once. Per-atom suspense breaks the synchronous-read guarantee (see idb for the hydration contract).
- Don't
useMemo around a use(promise) call — the React Compiler handles memoization (see react-compiler-rules); manual memo can mask a stale-Promise bug.
- Don't use
useTransition for urgent updates — typing into a controlled input must stay urgent; only wrap the downstream derivation in startTransition.
- Don't use
useFormStatus in the same component that owns the <form> — it returns { pending: false, data: null, ... } because there's no parent form. Move it into a child.
- Don't write a
useEffect that polls a Promise — use(promise) + <Suspense> is the React-19 way; the effect+poll pattern is a React-17 idiom that doesn't belong here.
- Don't import
Suspense from somewhere other than react — there is exactly one Suspense and it lives in react. react-dom exports useFormStatus only.
Triggers on
use(promise), useTransition, useDeferredValue, useOptimistic, useActionState, useFormStatus, Suspense, react 19 hook