Lifecycle

How resources render, mount, update, and unmount.

Render and commit

Like React, tap splits work into two phases:

  1. Render: the resource function runs, hooks record their data, and a return value is produced. This phase has no side effects.
  2. 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, 3

This 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 useResource unmounts
  • root.unmount() is called on a createTapRoot root

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>.

  • useResource inherits the surrounding <StrictMode>.
  • createTapRoot enables it automatically in development.