@assistant-ui/tap
tap (Reactive Resources) is a zero-dependency reactive state management library that brings React's hooks mental model to state management outside of React components.
Installation
npm install @assistant-ui/tapWhat is tap?
Instead of limiting hooks to React components, tap lets you use the same familiar hooks pattern (useState, useEffect, useMemo, etc.) to create self-contained, reusable units of reactive state and logic called Resources that can be used anywhere - in vanilla JavaScript, servers, or outside of React.
Philosophy
- Unified mental model: Use the same hooks pattern everywhere
- Framework agnostic: Zero dependencies, works with or without React
- Lifecycle management: Resources handle their own cleanup automatically
- Type-safe: Full TypeScript support with proper type inference
How It Works
tap implements a render-commit pattern similar to React:
Render Phase
- Each resource instance has a "fiber" that tracks state and effects
- When a resource function runs, hooks record their data in the fiber
- The library maintains an execution context to track which fiber's hooks are being called
- Each hook stores its data in cells indexed by call order (enforcing React's rules)
Commit Phase
- After render, collected effect tasks are processed
- Effects check if dependencies changed using shallow equality
- Old effects are cleaned up before new ones run
- Updates are batched using microtasks to prevent excessive re-renders
Core Concepts
Resources
Resources are self-contained units of reactive state and logic. They follow the same rules as React hooks:
- Hook Order: Hooks must be called in the same order in every render
- No Conditional Hooks: Can't call hooks inside conditionals or loops
- No Async Hooks: Hooks must be called synchronously during render
- Resources automatically handle cleanup and lifecycle
Creating Resources
import { createResource, tapState, tapEffect } from "@assistant-ui/tap";
// Define a resource using familiar hook patterns
const Counter = resource(({ incrementBy = 1 }: { incrementBy?: number }) => {
const [count, setCount] = tapState(0);
tapEffect(() => {
console.log(`Count is now: ${count}`);
}, [count]);
return {
count,
increment: () => setCount((c) => c + incrementBy),
decrement: () => setCount((c) => c - incrementBy),
};
});
// Create an instance
const counter = createResource(new Counter({ incrementBy: 2 }));
// Subscribe to changes
const unsubscribe = counter.subscribe(() => {
console.log("Counter value:", counter.getState().count);
});
// Use the resource
counter.getState().increment();resource
Creates a resource element factory. Resource elements are plain objects of the type { type: ResourceFn<R, P>, props: P, key?: string | number }.
const Counter = resource(({ incrementBy = 1 }: { incrementBy?: number }) => {
const [count, setCount] = tapState(0);
});
// create a Counter element
const counterEl = new Counter({ incrementBy: 2 });
// create a Counter instance
const counter = createResource(counterEl);
counter.dispose();Hook APIs
tapState
Manages local state within a resource, exactly like React's useState.
const [value, setValue] = tapState(initialValue);
const [value, setValue] = tapState(() => computeInitialValue());tapEffect
Runs side effects with automatic cleanup, exactly like React's useEffect.
tapEffect(() => {
// Effect logic
return () => {
// Cleanup logic
};
}, [dependencies]);tapMemo
Memoizes expensive computations, exactly like React's useMemo.
const expensiveValue = tapMemo(() => {
return computeExpensiveValue(dep1, dep2);
}, [dep1, dep2]);tapCallback
Memoizes callbacks to prevent unnecessary re-renders, exactly like React's useCallback.
const stableCallback = tapCallback(() => {
doSomething(value);
}, [value]);tapRef
Creates a mutable reference that persists across renders, exactly like React's useRef.
// With initial value
const ref = tapRef(initialValue);
ref.current = newValue;
// Without initial value
const ref = tapRef<string>(); // ref.current is undefined
ref.current = "hello";tapResource
Composes resources together - resources can render other resources.
const Timer = resource(() => {
const counter = tapResource({ type: Counter, props: { incrementBy: 1 } });
tapEffect(() => {
const interval = setInterval(() => {
counter.increment();
}, 1000);
return () => clearInterval(interval);
}, []);
return counter.count;
});tapResources
Renders multiple resources with keys, similar to React's list rendering. All resources must have a unique key property.
const TodoItem = resource((props: { text: string }) => {
const [completed, setCompleted] = tapState(false);
return { text: props.text, completed, setCompleted };
});
const TodoList = resource(() => {
const todos = [
{ id: "1", text: "Learn tap" },
{ id: "2", text: "Build something awesome" },
];
const todoItems = tapResources(
todos.map((todo) => new TodoItem({ text: todo.text }, { key: todo.id })),
);
return todoItems;
});tapContext and Context Support
Create and use context to pass values through resource boundaries without prop drilling.
import {
createContext,
tapContext,
withContextProvider,
} from "@assistant-ui/tap";
const MyContext = createContext(defaultValue);
// Provide context
withContextProvider(MyContext, value, () => {
// Inside this function, tapContext can access the value
});
// Access context in a resource
const value = tapContext(MyContext);Resource Management
createResource
Create an instance of a resource. This renders the resource and mounts the tapEffect hooks.
import { createResource } from "@assistant-ui/tap";
const handle = createResource(new Counter({ incrementBy: 1 }));
// Access current value
console.log(handle.getState().count);
// Subscribe to changes
const unsubscribe = handle.subscribe(() => {
console.log("Counter updated:", handle.getState());
});
// Update props to the resource
handle.updateInput({ incrementBy: 2 });
// Cleanup
handle.dispose();
unsubscribe();React Integration
Use resources directly in React components with the useResource hook:
import { useResource } from "@assistant-ui/tap/react";
function MyComponent() {
const state = useResource(new Counter({ incrementBy: 1 }));
return (
<div>
<p>Count: {state.count}</p>
<button onClick={state.increment}>Increment</button>
</div>
);
}Design Patterns
Automatic Cleanup
Resources automatically clean up after themselves when unmounted:
const WebSocketResource = resource(() => {
const [messages, setMessages] = tapState<string[]>([]);
tapEffect(() => {
const ws = new WebSocket("ws://localhost:8080");
ws.onmessage = (event) => {
setMessages((prev) => [...prev, event.data]);
};
// Cleanup happens automatically when resource unmounts
return () => ws.close();
}, []);
return messages;
});API Wrapper Pattern
A common pattern in assistant-ui is to wrap resource state in a stable API object:
export const tapApi = <TApi extends ApiObject & { getState: () => any }>(
api: TApi,
) => {
const ref = tapRef(api);
tapEffect(() => {
ref.current = api;
});
const apiProxy = tapMemo(
() =>
new Proxy<TApi>({} as TApi, new ReadonlyApiHandler(() => ref.current)),
[],
);
return tapMemo(
() => ({
state: api.getState(),
api: apiProxy,
}),
[api.getState()],
);
};Use Cases
tap is used throughout assistant-ui for:
- State Management: Application-wide state without Redux/Zustand
- Event Handling: Managing event subscriptions and cleanup
- Resource Lifecycle: Auto-cleanup of WebSockets, timers, subscriptions
- Composition: Nested resource management (threads, messages, tools)
- Context Injection: Passing values through resource boundaries without prop drilling
- API Wrapping: Creating reactive API objects with
getState()andsubscribe()
Example: Tools Management
export const Tools = resource(({ toolkit }: { toolkit?: Toolkit }) => {
const [state, setState] = tapState<ToolsState>(() => ({
tools: {},
}));
const modelContext = tapModelContext();
tapEffect(() => {
if (!toolkit) return;
// Register tools and setup subscriptions
const unsubscribes: (() => void)[] = [];
// ... registration logic
return () => unsubscribes.forEach((fn) => fn());
}, [toolkit, modelContext]);
return tapApi<ToolsApi>({
getState: () => state,
setToolUI,
});
});Why tap?
- Reuse React knowledge: Developers already familiar with hooks can immediately work with tap
- Framework flexibility: Core logic can work outside React components
- Automatic cleanup: No memory leaks from forgotten unsubscribes
- Composability: Resources can nest and combine naturally
- Type safety: Full TypeScript inference for state and APIs
- Zero dependencies: Lightweight and portable
Comparison with React Hooks
| React Hook | Reactive Resource | Behavior |
|---|---|---|
useState |
tapState |
Identical |
useEffect |
tapEffect |
Identical |
useMemo |
tapMemo |
Identical |
useCallback |
tapCallback |
Identical |
useRef |
tapRef |
Identical |
License
MIT