Files
wehub-resource-sync e30e75b5d4
Changesets / Create Version PR (push) Has been cancelled
Deploy Shadcn Registry / Deploy Production (push) Has been cancelled
Template Metrics / LOC + Bundle Size (push) Has been cancelled
Code Quality / Oxlint + Oxfmt (push) Has been cancelled
Code Quality / Template Sync (push) Has been cancelled
Code Quality / Build Changed Packages (push) Has been cancelled
Code Quality / Test Changed Packages (push) Has been cancelled
Deploy Expo Example / Deploy Production (push) Has been cancelled
Deploy Ink Example / Deploy Production (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.12) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.12) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:40:13 +08:00

263 lines
7.3 KiB
Plaintext

---
title: Child Scopes
description: Derive scopes from parent data with Derived, useClientResource, and useClientLookup.
---
So far, every scope has been an independent unit of state — you create it, provide it, and consume it. But what happens when you have a list of items that each need their own scope?
A chat app doesn't have one message — it has dozens. A todo app doesn't have one todo — it has a dynamic list. You can't register a separate scope for each item in `ScopeRegistry`. Instead, a **parent scope** manages the collection, and **child scopes** point to individual items within it.
## The pattern
A parent scope exposes a list of items. A child scope points to one item from that list. The wiring looks like this:
1. The parent resource uses `useClientLookup` to manage a collection of child clients
2. The parent returns a method to access a child by index or key
3. A `Derived` scope calls that method to resolve a specific child
```
todoList scope (parent)
└ manages a list of todo clients
└ exposes: todo({ index }) → methods
todo scope (child, via Derived)
└ points to one specific todo from the parent
```
## Step by step
### 1. Register both scopes
```ts title="lib/store/todo-scope.ts"
import "@assistant-ui/store";
declare module "@assistant-ui/store" {
interface ScopeRegistry {
todoList: {
methods: {
getState: () => { todos: { id: string; text: string; done: boolean }[] };
todo: (lookup: { index: number }) => ClientOutput<"todo">;
add: (text: string) => void;
};
};
todo: {
methods: {
getState: () => { id: string; text: string; done: boolean };
toggle: () => void;
remove: () => void;
};
meta: { source: "todoList"; query: { index: number } };
};
}
}
```
The `todo` scope declares `meta: { source: "todoList"; query: { index: number } }` — this tells TypeScript that `todo` is derived from `todoList` and is looked up by index.
### 2. Build the parent resource with useClientLookup
`useClientLookup` wraps a list of resource elements into clients that can be accessed by index or key:
```ts title="lib/store/todo-list-resource.ts"
import { resource, withKey } from "@assistant-ui/tap";
import { useClientLookup } from "@assistant-ui/store";
import type { ClientOutput } from "@assistant-ui/store";
import { useState, useMemo } from "react";
const useTodoResource = ({
id,
text,
done,
}: {
id: string;
text: string;
done: boolean;
}): ClientOutput<"todo"> => {
const [state, setState] = useState({ id, text, done });
return {
getState: () => state,
toggle: () => setState((s) => ({ ...s, done: !s.done })),
remove: () => {}, // filled in by parent
};
};
const TodoResource = resource(useTodoResource);
const useTodoListResource = (): ClientOutput<"todoList"> => {
const [items, setItems] = useState([
{ id: "1", text: "Learn Store", done: false },
]);
const todos = useClientLookup(
items.map((item) => withKey(item.id, TodoResource(item))),
);
const state = useMemo(
() => ({ todos: todos.state }),
[todos.state],
);
return {
getState: () => state,
todo: (lookup) => todos.get(lookup),
add: (text) =>
setItems((prev) => [
...prev,
{ id: crypto.randomUUID(), text, done: false },
]),
};
};
const TodoListResource = resource(useTodoListResource);
```
`useClientLookup` returns `{ state, get }`:
- `state` — an array of each child's `getState()` result
- `get({ index })` or `get({ key })` — resolves a specific child's methods
Each element must have a key via `withKey`. Keys let Store track identity across re-renders.
### 3. Use Derived to create the child scope
`Derived` creates a scope that points to one item from the parent:
```tsx title="app/TodoApp.tsx"
import { useAui, AuiProvider, useAuiState, Derived } from "@assistant-ui/store";
const TodoApp = () => {
const aui = useAui({ todoList: TodoListResource() });
return (
<AuiProvider value={aui}>
<TodoList />
</AuiProvider>
);
};
const TodoList = () => {
const todos = useAuiState((s) => s.todoList.todos);
return (
<div>
{todos.map((_, index) => (
<TodoItem key={index} index={index} />
))}
</div>
);
};
const TodoItem = ({ index }: { index: number }) => {
const aui = useAui({
todo: Derived({
source: "todoList",
query: { index },
get: (aui) => aui.todoList().todo({ index }),
}),
});
return (
<AuiProvider value={aui}>
<TodoDisplay />
</AuiProvider>
);
};
```
`Derived` takes three fields:
- `source` — the parent scope name
- `query` — the lookup parameters (passed to meta, used for debugging and event scoping)
- `get` — a function that resolves the child's methods from the parent. It receives the current `aui` store and must return the result of calling a parent method
### 4. Consume the child scope
Components inside the `AuiProvider` can now use the `todo` scope like any other:
```tsx title="app/TodoDisplay.tsx"
const TodoDisplay = () => {
const { text, done } = useAuiState((s) => s.todo);
const aui = useAui();
return (
<label>
<input
type="checkbox"
checked={done}
onChange={() => aui.todo().toggle()}
/>
{text}
</label>
);
};
```
`TodoDisplay` doesn't know or care that `todo` is derived — it uses `useAuiState` and `useAui` the same way it would for any scope.
## useClientResource
If your parent only needs to expose a single child (not a list), use `useClientResource` directly:
```ts
import { useClientResource } from "@assistant-ui/store";
const useThreadResource = (): ClientOutput<"thread"> => {
const composer = useClientResource(ComposerResource());
return {
getState: () => ({ ... }),
composer: () => composer.methods,
};
};
const ThreadResource = resource(useThreadResource);
```
`useClientResource` wraps a single resource element and returns `{ state, methods, key }`. It's what `useClientLookup` uses internally for each element.
## useClientList
For dynamic lists where users can add and remove items, use `useClientList`:
```ts
import { useClientList } from "@assistant-ui/store";
import { useState } from "react";
const useTodoItem = ({
getInitialData,
remove,
}: {
getInitialData: () => { id: string; text: string; done: boolean };
remove: () => void;
}): ClientOutput<"todo"> => {
const [state, setState] = useState(getInitialData());
return {
getState: () => state,
toggle: () => setState((s) => ({ ...s, done: !s.done })),
remove,
};
};
const TodoItemResource = resource(useTodoItem);
const useTodoListResource = (): ClientOutput<"todoList"> => {
const todos = useClientList({
initialValues: [{ id: "1", text: "Learn Store", done: false }],
getKey: (item) => item.id,
resource: TodoItemResource,
});
return {
getState: () => ({ todos: todos.state }),
todo: (lookup) => todos.get(lookup),
add: (text) => todos.add({ id: crypto.randomUUID(), text, done: false }),
};
};
const TodoListResource = resource(useTodoListResource);
```
`useClientList` extends `useClientLookup` with mutation:
- `add(data)` — adds a new item to the list
- Each child resource receives `{ key, getInitialData, remove }` as props
- `getInitialData()` returns the item data (called once on mount)
- `remove()` removes the item from the list