How resources render, mount, update, and unmount.
Render and commit
Like React, tap splits work into two phases:
- Render — the resource function runs, hooks record their data, and a return value is produced. This phase has no side effects.
- Commit — effects whose dependencies changed run. If an effect is re-running, its previous cleanup executes first. The resource is considered mounted after this phase.
Unlike React, calling set or dispatch during the render phase throws an error. To adjust state in response to changing props, use tapReducerWithDerivedState instead (tap's equivalent of React's getDerivedStateFromProps).
const App = resource(() => {
// --- render phase ---
const [count, setCount] = tapState(0);
tapEffect(() => {
// --- commit phase ---
console.log(`Mounted with count: ${count}`);
return () => console.log("Cleaned up");
}, [count]);
return { count };
});Effect ordering
In React, effects run children-first, then parents (inside-out). This is because components can only render children by returning them — there's no way to run an effect after a child's effects.
In tap, effects run in the exact order they are called during the render pass. Since tapResource is just another hook, you can place tapEffect calls before or after it:
const Parent = resource(() => {
tapEffect(() => {
console.log("1: before child");
});
const child = tapResource(Child());
tapEffect(() => {
console.log("3: after child");
});
return child;
});
const Child = resource(() => {
tapEffect(() => {
console.log("2: child");
});
});
// Mount order: 1, 2, 3This is intentional — it lets you run setup logic both before and after children, which is useful when a parent needs to react to data provided by a child.
Cleanup on unmount runs in the same order as mount (FIFO), matching the order effects were originally registered.
Mount and unmount
A resource is mounted after its first commit. At this point, effects have run and the resource is live.
A resource is unmounted when its owner removes it — all effect cleanups run and the resource is disposed. This happens automatically when:
- A parent resource stops rendering the child via
tapResource - A React component using
useResourceunmounts root.unmount()is called on acreateResourceRootroot
Concurrent mode
Tap has full interoperability with React's concurrent mode. Resources rendered via useResource work correctly with startTransition, useDeferredValue, <Suspense>, and other concurrent features — state updates are applied consistently without tearing.
The @assistant-ui/store layer currently relies on useSyncExternalStore to bridge tap resources into React. This means store subscriptions always trigger synchronous re-renders, opting out of concurrent scheduling for those reads. This may change in a future release.
Offscreen / Activity
Tap supports React's <Activity> (formerly "Offscreen") API. When a resource rendered via useResource is hidden by <Activity mode="hidden">, tap correctly handles the version gap that occurs when React commits without running effects. State updates that arrive while the resource is hidden are tracked in a changelog and replayed when the component becomes visible again, ensuring the resource stays consistent.
No special configuration is needed — useResource handles this automatically.
Strict mode
In development, tap supports React-style strict mode. When enabled, resources render twice on mount to help detect side effects in the render phase.
useResourceinherits React's strict mode setting automatically — if your component is inside<StrictMode>, the resource runs in strict mode too.createResourceRootautomatically enables strict mode in development.
This is the same behavior as React's <StrictMode> — double-rendering helps surface unintentional side effects during the render phase.