Composition

Combine Resources into reusable state trees.

Resources combine small stateful behaviors into larger ones. A parent Resource renders a child and receives its public API directly. It can read the child's state, call its methods, combine it with other Resources, or expose a new API of its own.

The result is a tree of focused Resources rather than one monolithic store.

useResource

A Hook call fixes its implementation at the call site. A ResourceElement makes that implementation an input, so a Resource can receive another Resource as configuration and render whichever implementation was provided:

import {
  resource,
  useResource,
  type ResourceElement,
} from "@assistant-ui/tap";

type CounterState = ReturnType<typeof useCounter>;

const useMockCounter = (count: number): CounterState => ({
  count,
  increment: () => undefined,
});

const MockCounter = resource(useMockCounter);

const useLimitedCounter = ({
  counter,
  max,
}: {
  counter: ResourceElement<CounterState>;
  max: number;
}) => {
  const state = useResource(counter);
  const isAtLimit = state.count >= max;

  return {
    count: state.count,
    isAtLimit,
    increment: () => {
      if (!isAtLimit) state.increment();
    },
  };
};

const LimitedCounter = resource(useLimitedCounter);

The caller chooses the counter implementation:

const counter = isDemo
  ? MockCounter(5)
  : Counter({ initialValue: 0 });

const limitedCounter = LimitedCounter({ counter, max: 10 });

LimitedCounter owns the limiting behavior while its caller decides where the count comes from. It accepts any Resource that returns CounterState.

When the configured Resource changes, useResource unmounts the previous implementation and mounts the next one. The useResource call itself stays in the same place, so the choice can remain dynamic without making Hook calls conditional.

useResources

Use useResources when a Resource owns a dynamic collection:

import { resource, withKey, useResources } from "@assistant-ui/tap";

const useCounterList = (ids: readonly string[]) => {
  const counters = useResources(
    ids.map((id) => withKey(id, Counter({ initialValue: 0 }))),
  );

  return {
    counters,
    total: counters.reduce((sum, counter) => sum + counter.count, 0),
  };
};

const CounterList = resource(useCounterList);

Every element in the collection needs a unique key. withKey gives each child a stable identity, so state follows the key when the collection is reordered:

CounterList(["a", "b", "c"]);
CounterList(["c", "a", "b"]);

Removing a key unmounts that Resource. Reusing a key with a different Resource implementation replaces the old Resource and its state.

Composition creates ownership

Rendering a Resource makes it a child of the current Resource:

LimitedCounter
└── Counter or MockCounter

CounterList
├── Counter key="a"
├── Counter key="b"
└── Counter key="c"

Children mount and unmount with their parent. Effects run in the order Resources are rendered. See Effect ordering for details.

Composition does not create a new scheduler. useResource and useResources remain part of the current Resource tree, so an update can propagate to its root. To introduce a separate scheduling boundary, use useTapRoot.