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.
import { resource } from "@assistant-ui/tap";
import { useState, useEffect } from "react";
const useApp = () => {
// --- render phase ---
const [count, setCount] = useState(0);
useEffect(() => {
// --- commit phase ---
console.log(`Mounted with count: ${count}`);
return () => console.log("Cleaned up");
}, [count]);
return { count };
};
const App = resource(useApp);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 useResource is just another hook, you can place useEffect calls before or after it:
import { resource, useResource } from "@assistant-ui/tap";
import { useEffect } from "react";
const useParent = () => {
useEffect(() => {
console.log("1: before child");
});
const child = useResource(Child());
useEffect(() => {
console.log("3: after child");
});
return child;
};
const Parent = resource(useParent);
const useChild = () => {
useEffect(() => {
console.log("2: child");
});
};
const Child = resource(useChild);
// 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
useResource - A React component using
useResourceunmounts root.unmount()is called on acreateTapRootroot
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.
Warning
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
When a resource is hidden by <Activity mode="hidden">, state updates that arrive while it's hidden are tracked and replayed once it becomes visible again. No configuration needed.
A createTapRoot root with mountOnSubscribe: true follows the same model when
its subscriber count drops to zero: effect cleanups run, state is preserved, and
the next subscriber remounts the tree — like an Activity going hidden and
visible again.
Strict mode
In development, resources render twice on mount to surface side effects in render, the same as React's <StrictMode>.
useResourceinherits the surrounding<StrictMode>.createTapRootenables it automatically in development.