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
145 lines
5.0 KiB
Plaintext
145 lines
5.0 KiB
Plaintext
---
|
|
title: State
|
|
description: Subscribe to state changes with useAuiState.
|
|
---
|
|
|
|
Scopes can define a special method called `getState()`. It's just a regular method but it has special integration in the Store: it powers `useAuiState`, `AuiIf` as well as the `subscribe` API.
|
|
|
|
## Defining state
|
|
|
|
First, register `getState` and its return type in `ScopeRegistry`:
|
|
|
|
```ts title="lib/store/counter-scope.ts"
|
|
import "@assistant-ui/store";
|
|
|
|
declare module "@assistant-ui/store" {
|
|
interface ScopeRegistry {
|
|
counter: {
|
|
methods: {
|
|
getState: () => { count: number; step: number };
|
|
increment: () => void;
|
|
setStep: (n: number) => void;
|
|
};
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
The return type of `getState` defines the shape of `s.counter` in `useAuiState((s) => s.counter.count)`. A scope doesn't have to define `getState()`: if it only has methods and no readable state, that's fine.
|
|
|
|
Then implement `getState()` in the resource. It should return a memoized object built from the resource's internal state:
|
|
|
|
```ts title="lib/store/counter-resource.ts"
|
|
import { resource } from "@assistant-ui/tap";
|
|
import { useState, useMemo } from "react";
|
|
import type { ClientOutput } from "@assistant-ui/store";
|
|
|
|
const useCounterResource = (): ClientOutput<"counter"> => {
|
|
const [count, setCount] = useState(0);
|
|
const [step, setStep] = useState(1);
|
|
|
|
const state = useMemo(() => ({ count, step }), [count, step]);
|
|
|
|
return {
|
|
getState: () => state,
|
|
increment: () => setCount((c) => c + step),
|
|
setStep: (n: number) => setStep(n),
|
|
};
|
|
};
|
|
|
|
const CounterResource = resource(useCounterResource);
|
|
```
|
|
|
|
Use `useMemo` to create the state object. This ensures a stable reference when nothing changed, since Store uses reference equality to detect updates. The `useState` values in the dependency array trigger a resource re-render when they change, which is when Store checks for new state.
|
|
|
|
## useAuiState
|
|
|
|
`useAuiState` subscribes to state. You must pass a selector function which selects a slice of the store to subscribe to. The component re-renders only when the selected value changes.
|
|
|
|
```tsx
|
|
const count = useAuiState((s) => s.counter.count);
|
|
```
|
|
|
|
The selector function receives a state object where each key corresponds to a scope. `s.counter` is the return value of the counter's `getState()`.
|
|
|
|
<Callout type="warn">
|
|
Avoid selecting more state than you need. Selecting a wide object (like an entire scope's state) means your component re-renders whenever *any* field in that object changes, even fields you don't use.
|
|
|
|
```tsx
|
|
// ❌ Avoid: re-renders on any counter state change
|
|
const { count } = useAuiState((s) => s.counter);
|
|
|
|
// ✅ Prefer: re-renders only when count changes
|
|
const count = useAuiState((s) => s.counter.count);
|
|
```
|
|
</Callout>
|
|
|
|
## Selecting from multiple scopes
|
|
|
|
The most common pitfall with `useAuiState` is returning a new object from the selector:
|
|
|
|
```tsx
|
|
// ❌ Infinite re-render: creates a new object every time
|
|
const summary = useAuiState((s) => ({
|
|
count: s.counter.count,
|
|
name: s.user.name,
|
|
}));
|
|
```
|
|
|
|
`useAuiState` compares the selector's return value by reference (`Object.is`). An object literal like `{ count, name }` creates a new reference every time the selector runs, so Store thinks the value changed, re-renders the component, which runs the selector again (infinite loop).
|
|
|
|
The fix is simple: use a separate `useAuiState` call for each value.
|
|
|
|
```tsx
|
|
const count = useAuiState((s) => s.counter.count);
|
|
const name = useAuiState((s) => s.user.name);
|
|
```
|
|
|
|
This is also more precise: the component only re-renders when the specific value it uses changes, not when any of the selected scopes change.
|
|
|
|
## Checking if a scope exists
|
|
|
|
Accessing `s.counter` in a selector throws if the `counter` scope hasn't been provided. To conditionally read state from a scope that may not exist, use `useAui()` to check `source` first:
|
|
|
|
```tsx
|
|
const aui = useAui();
|
|
const count = useAuiState(
|
|
() => aui.counter.source !== null && aui.counter().getState().count,
|
|
);
|
|
```
|
|
|
|
When `source` is `null`, the selector short-circuits and returns `false` instead of accessing the missing scope. When the scope is available, it resolves and reads the state normally.
|
|
|
|
## AuiIf
|
|
|
|
`AuiIf` renders its children only when a state condition is true:
|
|
|
|
```tsx
|
|
<AuiIf condition={(s) => s.counter.count > 0}>
|
|
<ResetButton />
|
|
</AuiIf>
|
|
```
|
|
|
|
It uses `useAuiState` internally, so it re-evaluates only when the selected values change.
|
|
|
|
## Under the hood: subscribe + getState
|
|
|
|
Both `useAuiState` and `AuiIf` are built on two primitives available on the store:
|
|
|
|
- `aui.subscribe(callback)`: calls `callback` whenever any scope's state changes
|
|
- `aui.counter().getState()`: returns the current state snapshot
|
|
|
|
Together, they form the standard `useSyncExternalStore` pattern. You can use them directly for non-React integrations or when you need imperative access:
|
|
|
|
```ts
|
|
const aui = useAui();
|
|
|
|
// imperative read
|
|
const count = aui.counter().getState().count;
|
|
|
|
// manual subscription
|
|
const unsub = aui.subscribe(() => {
|
|
console.log("new count:", aui.counter().getState().count);
|
|
});
|
|
```
|