A lightweight, type-safe library for reactive state management. Cells simplifies complex state propagation with an intuitive API that handles synchronous updates, asynchronous data fetching, and race conditions automatically.
- Fine-grained Reactivity - Updates only what changes, avoiding unnecessary re-renders
- Async Primitives - First-class support for async state with built-in loading and error tracking
- Race Condition Handling - Automatically cancels stale async requests via
AbortSignal - Glitch-free - Guarantees consistency across derived values with topological update ordering
- Type-safe - Full TypeScript support with inferred types
- Zero Dependencies - Keeps your bundle small (~3KB minified)
npm install @adbl/cellsyarn add @adbl/cellspnpm add @adbl/cellsimport { Cell } from '@adbl/cells';
// 1. Create a source cell
const name = Cell.source('World');
// 2. Create a derived cell (updates automatically)
const greeting = Cell.derived(() => `Hello, ${name.get()}!`);
// 3. Listen for changes
greeting.listen((msg) => console.log(msg));
// 4. Update the source
name.set('Cells'); // Console: "Hello, Cells!"The root of your state graph. You can read, subscribe to, and modify them.
const count = Cell.source(0);
count.set(1);
console.log(count.get()); // 1Computed values that update automatically when dependencies change. They are eager and always kept in sync.
const count = Cell.source(1);
const double = Cell.derived(() => count.get() * 2);
console.log(double.get()); // 2
count.set(5);
console.log(double.get()); // 10Run side effects when a cell changes.
const count = Cell.source(0);
// Runs only on updates
const unsubscribe = count.listen((val) => console.log(val));
// Runs immediately, then on updates
count.runAndListen((val) => console.log('Current:', val));
// Cleanup when done
unsubscribe();Cells shines when handling async operations, replacing manual promise handling with declarative primitives.
Use Cell.derivedAsync for data fetching or heavy computations. It automatically exposes pending and error states.
const userId = Cell.source(1);
const user = Cell.derivedAsync(async (get, signal) => {
// 'get' tracks dependencies
const id = get(userId);
// 'signal' handles cancellation automatically if userId changes
const res = await fetch(`/api/users/${id}`, { signal });
return res.json();
});
// Built-in status tracking
user.pending.listen((isLoading) => console.log(isLoading ? 'Loading...' : 'Done'));
user.error.listen((err) => err && console.error(err));
// Access the data
const data = await user.get();Use Cell.task for user-triggered actions (e.g., form submissions). Unlike derived cells, these only execute when triggered with runWith.
const login = Cell.task(async (creds, signal) => {
const res = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify(creds),
signal,
});
return res.json();
});
// Trigger the task
const result = await login.runWith({ user: 'admin', pass: '1234' });
// Track status
login.pending.listen((isPending) => {
submitButton.disabled = isPending;
});Group multiple async cells into a synchronized unit. Useful for preventing partial updates or ensuring "all-or-nothing" behavior.
const profile = Cell.derivedAsync(fetchProfile);
const posts = Cell.derivedAsync(fetchPosts);
// Waits for BOTH to finish before updating
const dashboard = Cell.composite({ profile, posts });
dashboard.pending.listen((isPending) => showSpinner(isPending));
dashboard.error.listen((err) => err && showError(err));
dashboard.loaded.listen(async (ready) => {
if (ready) {
const profileData = await dashboard.values.profile.get();
const postsData = await dashboard.values.posts.get();
renderDashboard(profileData, postsData);
}
});Group multiple updates into a single notification to avoid unnecessary re-computations.
Cell.batch(() => {
firstName.set('John');
lastName.set('Doe');
// Effects run once here, after the block finishes
});Read a value without subscribing to it.
const sum = Cell.derived(() => {
// Re-runs if 'a' changes, but NOT if 'b' changes
return a.get() + b.peek();
});Customize how cells detect changes.
const user = Cell.source(
{ id: 1, name: 'Alice' },
{
equals: (a, b) => a.id === b.id, // Only update if ID changes
}
);For high-performance scenarios involving many dynamically created cells, use LocalContext for manual disposal.
const ctx = Cell.context();
Cell.runWithContext(ctx, () => {
// All listeners and derived cells created here are bound to 'ctx'
source.listen(handler);
const derived = Cell.derived(() => source.get() * 2);
});
// Clean up everything at once
ctx.destroy();| Method | Description |
|---|---|
source(value, options?) |
Creates a mutable source cell. |
derived(fn) |
Creates a computed cell from other cells. |
derivedAsync(fn) |
Creates an async computed cell with cancellation support. |
task(fn) |
Creates a triggerable async task. |
composite(map) |
Combines multiple cells into a synchronized object. |
batch(fn) |
Batches updates to prevent multiple effect triggers. |
context() |
Creates a new LocalContext for scoped memory management. |
runWithContext(ctx, fn) |
Executes a function within a specific LocalContext. |
isCell(value) |
Returns true if the value is a Cell. |
| Method | Description |
|---|---|
get() |
Returns the value. Registers dependency if in a derivation. |
peek() |
Returns the value without registering a dependency. |
listen(callback, options?) |
Subscribes to changes. Returns an unsubscribe function. |
runAndListen(callback, options?) |
Runs callback immediately, then subscribes to future changes. |
ignore(callback) |
Removes a previously registered listener. |
valueOf() |
Returns the raw value (for implicit coercion). |
toString() |
Returns the stringified value. |
| Method | Description |
|---|---|
set(value) |
Updates the cell's value and notifies listeners. |
Available on AsyncDerivedCell and AsyncTaskCell:
| Property/Method | Description |
|---|---|
pending |
A Cell<boolean> indicating loading state. |
error |
A Cell<Error | null> holding the last error. |
get() |
Returns a Promise that resolves to the value. |
peek() |
Returns a Promise without registering dependencies. |
| Method | Description |
|---|---|
revalidate() |
Forces a refresh of the async computation. |
| Method | Description |
|---|---|
runWith(input) |
Executes the task with the given input. Returns a Promise. |
Returned by Cell.composite():
| Property | Description |
|---|---|
values |
Object containing synchronized async cells for each input key. |
pending |
A Cell<boolean> that is true while any input is pending. |
error |
A Cell<Error | null> with the first error from any input. |
loaded |
A Cell<boolean> that becomes true after initial load completes. |
| Method | Description |
|---|---|
destroy() |
Disposes all listeners and derived cells bound to this context. |
Options for listen() and runAndListen():
| Option | Type | Description |
|---|---|---|
once |
boolean |
Remove the listener after it fires once. |
signal |
AbortSignal |
Automatically remove listener when signal aborts. |
weak |
boolean |
Use a weak reference (listener may be garbage collected). |
priority |
number |
Execution order (higher runs first, default: 0). |
Options for Cell.source():
| Option | Type | Description |
|---|---|---|
equals |
(a, b) => boolean |
Custom equality function for change detection. |
Cells is written in JavaScript with comprehensive JSDoc annotations and ships with TypeScript declaration files.
import { Cell, SourceCell, DerivedCell, AsyncDerivedCell } from '@adbl/cells';
// Types are inferred automatically
const count: SourceCell<number> = Cell.source(0);
const doubled: DerivedCell<number> = Cell.derived(() => count.get() * 2);
// Async cells with proper typing
const user: AsyncDerivedCell<User> = Cell.derivedAsync(async (get) => {
const id = get(userId);
const res = await fetch(`/api/users/${id}`);
return res.json() as User;
});
// Task cells with input/output types
const submitForm = Cell.task(async (data: FormData, signal: AbortSignal) => {
const res = await fetch('/api/submit', { method: 'POST', body: data, signal });
return res.json() as SubmitResult;
});Contributions are welcome! Here's how to get started:
# Clone the repository
git clone https://github.com/adebola-io/signals.git
cd signals
# Install dependencies
npm install
# Run tests in watch mode
npm test
# Run tests once
npm run test-once
# Build the project
npm run buildnpm testThe test suite uses Vitest and covers all core functionality including async behavior and race conditions.