Zero-dependency reactive state management inspired by React hooks
npm install @assistant-ui/taptap (Reactive Resources) is a zero-dependency reactive state management library that brings React's hooks mental model to state management outside of React components.
``bash`
npm install @assistant-ui/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.
- 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
tap implements a render-commit pattern similar to React:
1. Each resource instance has a "fiber" that tracks state and effects
2. When a resource function runs, hooks record their data in the fiber
3. The library maintains an execution context to track which fiber's hooks are being called
4. Each hook stores its data in cells indexed by call order (enforcing React's rules)
1. After render, collected effect tasks are processed
2. Effects check if dependencies changed using shallow equality
3. Old effects are cleaned up before new ones run
4. Updates are batched using microtasks to prevent excessive re-renders
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
`typescript
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();
`
Creates a resource element factory. Resource elements are plain objects of the type { type: ResourceFn.
`typescript
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();
`
Manages local state within a resource, exactly like React's useState.
`typescript`
const [value, setValue] = tapState(initialValue);
const [value, setValue] = tapState(() => computeInitialValue());
Runs side effects with automatic cleanup, exactly like React's useEffect.
`typescript`
tapEffect(() => {
// Effect logic
return () => {
// Cleanup logic
};
}, [dependencies]);
Memoizes expensive computations, exactly like React's useMemo.
`typescript`
const expensiveValue = tapMemo(() => {
return computeExpensiveValue(dep1, dep2);
}, [dep1, dep2]);
Memoizes callbacks to prevent unnecessary re-renders, exactly like React's useCallback.
`typescript`
const stableCallback = tapCallback(() => {
doSomething(value);
}, [value]);
Creates a mutable reference that persists across renders, exactly like React's useRef.
`typescript
// With initial value
const ref = tapRef(initialValue);
ref.current = newValue;
// Without initial value
const ref = tapRef
ref.current = "hello";
`
Composes resources together - resources can render other resources.
`typescript
const Timer = resource(() => {
const counter = tapResource({ type: Counter, props: { incrementBy: 1 } });
tapEffect(() => {
const interval = setInterval(() => {
counter.increment();
}, 1000);
return () => clearInterval(interval);
}, []);
return counter.count;
});
`
Renders multiple resources from an array, similar to React's list rendering. Returns an array with each resource's result.
`typescript`
tapResources
getElements: () => readonly E[],
getElementsDeps: readonly any[]
): ExtractResourceReturnType
Parameters:
- getElements: A function that returns an array of ResourceElementsgetElementsDeps
- : Dependency array for memoizing the getElements function
Example:
`typescript
const TodoItem = resource((props: { text: string }) => {
const [completed, setCompleted] = tapState(false);
return { text: props.text, completed, setCompleted };
});
const TodoList = resource(() => {
const todos = tapMemo(
() => [
{ id: "1", text: "Learn tap" },
{ id: "2", text: "Build something awesome" },
],
[],
);
// Returns Array<{ text, completed, setCompleted }>
const todoItems = tapResources(
() => todos.map((todo) => TodoItem({ text: todo.text })),
[todos]
);
return todoItems;
});
`
Key features:
- Resource instances are preserved when keys remain the same (use withKey() to provide stable keys)
- Automatically cleans up resources when removed from the array
- Handles resource type changes (recreates fiber if type changes)
Create and use context to pass values through resource boundaries without prop drilling.
`typescript
import {
createResourceContext,
tap,
withContextProvider,
} from "@assistant-ui/tap";
const MyContext = createResourceContext(defaultValue);
// Provide context
withContextProvider(MyContext, value, () => {
// Inside this function, tap can access the value
});
// Access context in a resource
const value = tap(MyContext);
`
Create an instance of a resource. This renders the resource and mounts the tapEffect hooks.
`typescript
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();
`
Use resources directly in React components with the useResource hook:
`typescript
import { useResource } from "@assistant-ui/tap/react";
function MyComponent() {
const state = useResource(new Counter({ incrementBy: 1 }));
return (
Count: {state.count}
Design Patterns
$3
Resources automatically clean up after themselves when unmounted:
`typescript
const WebSocketResource = resource(() => {
const [messages, setMessages] = tapState([]); 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;
});
`$3
A common pattern in assistant-ui is to wrap resource state in a stable API object:
`typescript
export const tapApi = any }>(
api: TApi,
) => {
const ref = tapRef(api); tapEffect(() => {
ref.current = api;
});
const apiProxy = tapMemo(
() =>
new Proxy({} as TApi, new ReadonlyApiHandler(() => ref.current)),
[],
);
return tapMemo(
() => ({
state: api.getState(),
api: apiProxy,
}),
[api.getState()],
);
};
`Use Cases
tap is used throughout assistant-ui for:
1. State Management: Application-wide state without Redux/Zustand
2. Event Handling: Managing event subscriptions and cleanup
3. Resource Lifecycle: Auto-cleanup of WebSockets, timers, subscriptions
4. Composition: Nested resource management (threads, messages, tools)
5. Context Injection: Passing values through resource boundaries without prop drilling
6. API Wrapping: Creating reactive API objects with
getState() and subscribe()$3
`typescript
export const Tools = resource(({ toolkit }: { toolkit?: Toolkit }) => {
const [state, setState] = tapState(() => ({
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({
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 |MIT