@stateloom/atom
Bottom-up atomic state composition. If you've used Jotai, this API will feel immediately familiar.
Install
pnpm add @stateloom/core @stateloom/atomnpm install @stateloom/core @stateloom/atomyarn add @stateloom/core @stateloom/atomSize: ~0.8 KB gzipped (+ core)
Overview
Atoms are the smallest unit of state. You compose complex state by deriving atoms from other atoms. Values live in a Scope (not in the atom itself), enabling SSR isolation and garbage collection.
Quick Start
import { atom, derived } from '@stateloom/atom';
// Base atoms
const countAtom = atom(0);
const nameAtom = atom('Alice');
// Derived atom — automatically tracks dependencies
const greetingAtom = derived((get) => {
return `Hello, ${get(nameAtom)}! Count: ${get(countAtom)}`;
});
// React usage (via @stateloom/react)
function Counter() {
const [count, setCount] = useAtom(countAtom);
const greeting = useAtomValue(greetingAtom);
return (
<div>
<p>{greeting}</p>
<button onClick={() => setCount((c) => c + 1)}>+</button>
</div>
);
}Guide
Creating Base Atoms
Atoms are config objects that describe a piece of state. The actual values live in a Scope:
import { atom } from '@stateloom/atom';
const countAtom = atom(0);
const nameAtom = atom('Alice');
const todosAtom = atom<Todo[]>([]);Deriving Values
Use derived to create read-only atoms computed from other atoms. Dependencies are automatically tracked:
import { atom, derived } from '@stateloom/atom';
const priceAtom = atom(10);
const quantityAtom = atom(3);
const totalAtom = derived((get) => get(priceAtom) * get(quantityAtom));Writing Custom Logic
Use writableAtom to create atoms with custom write logic that can update multiple atoms:
import { atom, writableAtom } from '@stateloom/atom';
const countAtom = atom(0);
const incrementAtom = writableAtom(null, (get, set) => {
set(countAtom, get(countAtom) + 1);
});Async Atoms
Derived atoms can be async. In React, they integrate with Suspense:
const userIdAtom = atom(1);
const userAtom = derived(async (get) => {
const id = get(userIdAtom);
const response = await fetch(`/api/users/${id}`);
return response.json() as Promise<User>;
});Atom Families
Create parameterized atoms for collections:
import { atomFamily } from '@stateloom/atom';
const todoAtom = atomFamily((id: string) => atom<Todo>({ id, text: '', done: false }));
const todo1 = todoAtom('todo-1');
const todo2 = todoAtom('todo-2');API Reference
atom<T>(initialValue: T): Atom<T>
Create a base writable atom.
Parameters:
| Parameter | Type | Description | Default |
|---|---|---|---|
initialValue | T | The initial value of the atom. | -- |
Returns: Atom<T> -- a writable atom with get(), set(), update(), and subscribe() methods.
import { atom } from '@stateloom/atom';
const countAtom = atom(0); // Atom<number>
const userAtom = atom<User | null>(null); // explicit generic
const todosAtom = atom<Todo[]>([]); // Atom<Todo[]>
countAtom.get(); // 0
countAtom.set(5);
countAtom.update((n) => n + 1); // 6Key behaviors:
- Atoms are config objects, not value containers -- the actual values live in an
AtomScope - Convenience methods (
get,set,update,subscribe) operate on the default global scope - For SSR, use
createAtomScope()and callscope.get(atom)/scope.set(atom, value) - Equality is checked with
Object.isby default - Atoms implement
Subscribable<T>-- they work directly with framework adapters
See also: derived(), writableAtom(), createAtomScope()
derived<T>(read: (get: AtomGetter) => T): ReadonlyAtom<T>
Create a read-only derived atom. The get helper reads other atoms and automatically tracks them as dependencies.
Parameters:
| Parameter | Type | Description | Default |
|---|---|---|---|
read | (get: AtomGetter) => T | Derivation function. Receives get to read dependencies. May be async. | -- |
Returns: ReadonlyAtom<T> -- a read-only atom whose value is derived from read.
import { atom, derived } from '@stateloom/atom';
const countAtom = atom(0);
const doubledAtom = derived((get) => get(countAtom) * 2);
doubledAtom.get(); // 0
countAtom.set(5);
doubledAtom.get(); // 10
// Multi-dependency
const todosAtom = atom<Todo[]>([]);
const activeCountAtom = derived((get) => {
return get(todosAtom).filter((todo) => !todo.done).length;
});Key behaviors:
- Lazy: The read function only runs when the value is accessed and stale
- Memoized: If dependencies haven't changed, the cached value is returned
- Glitch-free: Diamond dependency graphs always see consistent state
- Dependencies are tracked dynamically -- conditional branches only track the atoms actually read
- Async derivations return
Promise<T>and integrate with React Suspense
See also: atom(), writableAtom()
writableAtom<Value, Args, Result>(read, write): WritableAtom<Value, Args, Result>
Create a derived atom with custom write logic. Combines a read derivation (or null for write-only) with a custom write function.
Parameters:
| Parameter | Type | Description | Default |
|---|---|---|---|
read | ((get: AtomGetter) => Value) | null | Read derivation, or null for write-only atoms. | -- |
write | (get: AtomGetter, set: AtomSetter, ...args: Args) => Result | Custom write function. | -- |
Returns: WritableAtom<Value, Args, Result> -- a derived atom with a write() method.
import { atom, writableAtom } from '@stateloom/atom';
const countAtom = atom(0);
// Write-only atom (action)
const incrementAtom = writableAtom(null, (get, set) => {
set(countAtom, get(countAtom) + 1);
});
incrementAtom.write(); // countAtom becomes 1
// Write atom with arguments
const addAtom = writableAtom(null, (get, set, amount: number) => {
set(countAtom, get(countAtom) + amount);
});
addAtom.write(5); // countAtom becomes 6
// Read + write atom (bidirectional conversion)
const celsiusAtom = atom(0);
const fahrenheitAtom = writableAtom(
(get) => (get(celsiusAtom) * 9) / 5 + 32,
(get, set, fahrenheit: number) => {
set(celsiusAtom, ((fahrenheit - 32) * 5) / 9);
},
);
fahrenheitAtom.get(); // 32
fahrenheitAtom.write(212);
celsiusAtom.get(); // 100Key behaviors:
- Write-only atoms (
read: null) always returnnullfromget() - The
setfunction only accepts base atoms created byatom()-- to trigger another writable atom's write, call itswrite()method directly - Multiple
set()calls within a singlewrite()are batched together - The write function executes synchronously
atomFamily<Param, Result>(factory: (param: Param) => Result): (param: Param) => Result
Create a memoized atom factory for parameterized atoms. Repeated calls with the same parameter return the same atom instance.
Parameters:
| Parameter | Type | Description | Default |
|---|---|---|---|
factory | (param: Param) => Result | Function that creates an atom for a given parameter. | -- |
Returns: A memoized factory function with a remove(param) method for cache eviction.
import { atom, atomFamily } from '@stateloom/atom';
interface Todo {
id: string;
text: string;
done: boolean;
}
const todoAtom = atomFamily((id: string) => atom<Todo>({ id, text: '', done: false }));
const todo1 = todoAtom('todo-1'); // Atom<Todo>
const todo2 = todoAtom('todo-2'); // Different atom
todoAtom('todo-1') === todo1; // true -- memoized
// Evict a cached atom
todoAtom.remove('todo-1');Key behaviors:
- Parameters are compared with
SameValueZero(same asMap.has()) -- use string keys for stable lookups - Atoms are cached indefinitely until
remove(param)is called - The factory function is called at most once per unique parameter
WARNING
Atoms are cached indefinitely. For dynamic collections, call atomFamily.remove(param) to evict entries when items are removed.
See also: atom()
createAtomScope(): AtomScope
Create a new isolated atom scope. Each scope maintains its own set of atom values -- changes in one scope do not affect another.
Parameters: None.
Returns: AtomScope -- a new atom scope.
import { atom, createAtomScope } from '@stateloom/atom';
const countAtom = atom(0);
const scope1 = createAtomScope();
const scope2 = createAtomScope();
scope1.set(countAtom, 10);
scope2.set(countAtom, 20);
scope1.get(countAtom); // 10
scope2.get(countAtom); // 20
countAtom.get(); // 0 -- default scope unchangedKey behaviors:
- Scopes are lightweight -- signals are allocated lazily on first atom access
- Signals are cached per-atom in a
WeakMap-- repeated access is O(1) - When the scope is garbage collected, all its signals are collected too
- Use for SSR isolation (one scope per request) or testing (one scope per test)
See also: AtomScope
Atom<T> (interface)
A writable atom that holds a value. Extends Subscribable<T> from @stateloom/core.
interface Atom<T> extends Subscribable<T> {
get(): T;
set(value: T): void;
update(fn: (current: T) => T): void;
subscribe(callback: (value: T) => void): () => void;
}ReadonlyAtom<T>
A read-only derived atom. Alias for Subscribable<T>.
type ReadonlyAtom<T> = Subscribable<T>;WritableAtom<Value, Args, Result> (interface)
A derived atom with custom write logic. Extends Subscribable<Value>.
interface WritableAtom<Value, Args extends unknown[], Result> extends Subscribable<Value> {
write(...args: Args): Result;
}AtomScope (interface)
An isolated container for atom values.
interface AtomScope {
get<T>(atom: AnyReadableAtom<T>): T;
set<T>(atom: Atom<T>, value: T): void;
sub<T>(atom: AnyReadableAtom<T>, callback: (value: T) => void): () => void;
}| Method | Description |
|---|---|
get(atom) | Read an atom's value within this scope. |
set(atom, value) | Set a base atom's value within this scope. |
sub(atom, callback) | Subscribe to an atom's value changes within this scope. Returns unsubscribe. |
AtomGetter
Function to read an atom's current value within a derivation. Passed as the first argument to derived() and writableAtom() read/write functions.
type AtomGetter = <V>(atom: Subscribable<V>) => V;AtomSetter
Function to write a value to a base atom within a write derivation. Passed as the second argument to writableAtom() write functions.
type AtomSetter = <V>(atom: Atom<V>, value: V) => void;AnyReadableAtom<T>
Union type of all atoms whose value can be read.
type AnyReadableAtom<T = unknown> = Atom<T> | ReadonlyAtom<T> | WritableAtom<T, unknown[], unknown>;Patterns
Atom-in-Atom
Compose atoms by reading other atoms in derived atoms:
import { atom, derived } from '@stateloom/atom';
const filterAtom = atom<'all' | 'active' | 'done'>('all');
const todosAtom = atom<Todo[]>([]);
const filteredTodosAtom = derived((get) => {
const filter = get(filterAtom);
const todos = get(todosAtom);
if (filter === 'all') return todos;
return todos.filter((t) => (filter === 'active' ? !t.done : t.done));
});Reset Pattern
import { atom, writableAtom } from '@stateloom/atom';
const initialCount = 0;
const countAtom = atom(initialCount);
const resetAtom = writableAtom(null, (_get, set) => {
set(countAtom, initialCount);
});
resetAtom.write(); // countAtom resets to 0Storage Sync
import { atom } from '@stateloom/atom';
import { effect } from '@stateloom/core';
const themeAtom = atom<'light' | 'dark'>(
(typeof window !== 'undefined' ? (localStorage.getItem('theme') as 'light' | 'dark') : null) ??
'light',
);
// Sync to storage via effect
effect(() => {
const theme = themeAtom.get();
localStorage.setItem('theme', theme);
return undefined;
});How It Works
Atom Config vs Value Storage
Unlike signals (which hold values internally), atoms are config objects registered in a module-level WeakMap. The actual state lives in an AtomScope:
This separation enables SSR isolation -- the same atom definitions are shared across requests while each request gets its own scope with independent values.
Lazy Signal Creation
When an atom is first accessed in a scope, the scope creates the appropriate core primitive:
| Config Kind | Core Primitive Created |
|---|---|
BASE | signal(config.init) |
DERIVED | computed(() => config.read(getter)) |
WRITABLE (with read) | computed(() => config.read(getter)) |
WRITABLE (null read) | signal(null) |
Subsequent accesses return the cached subscribable in O(1) via WeakMap lookup.
Dependency Tracking
When a derived atom reads other atoms via get(), dependencies are tracked automatically by the core reactive graph. The scope-bound getter resolves each atom to its underlying core subscribable, and .get() registers the dependency:
- Base atom value changes -- underlying signal's version increments
- All derived atoms that read it are marked stale (MAYBE_DIRTY)
- On next read, stale derived atoms recompute
- Subscribers are notified only if the computed value changed
Subscription via Effect
Atom subscriptions use core effect() rather than raw subscribe(). This bridges a gap: core's computed.subscribe() is lazy (only fires when .get() is called after a change), but atom consumers expect eager notification. The effect eagerly tracks the subscribable and calls the callback on each change, skipping the initial value.
Atom Families
atomFamily maintains an internal Map<Param, Atom>. The same parameter always returns the same atom instance, ensuring consistent identity across reads. Parameters are compared with SameValueZero (Map's default).
TypeScript
Types flow through the atom dependency graph automatically:
import { atom, derived, writableAtom, atomFamily, createAtomScope } from '@stateloom/atom';
import { expectTypeOf } from 'vitest';
// Base atom type inferred from initial value
const countAtom = atom(0);
expectTypeOf(countAtom.get()).toEqualTypeOf<number>();
const nameAtom = atom('Alice');
expectTypeOf(nameAtom.get()).toEqualTypeOf<string>();
// Derived type inferred from return value
const greetingAtom = derived((get) => {
return `${get(nameAtom)}: ${String(get(countAtom))}`;
});
expectTypeOf(greetingAtom.get()).toEqualTypeOf<string>();
// Explicit generic for union/complex types
const userAtom = atom<User | null>(null);
expectTypeOf(userAtom.get()).toEqualTypeOf<User | null>();
// WritableAtom infers read type and write args
const addAtom = writableAtom(
(get) => get(countAtom),
(get, set, amount: number) => {
set(countAtom, get(countAtom) + amount);
},
);
expectTypeOf(addAtom.get()).toEqualTypeOf<number>();
// atomFamily preserves the atom type
const todoFamily = atomFamily((id: string) => atom({ id, done: false }));
expectTypeOf(todoFamily('x').get()).toEqualTypeOf<{ id: string; done: boolean }>();
// Scope methods are type-safe
const scope = createAtomScope();
expectTypeOf(scope.get(countAtom)).toEqualTypeOf<number>();When to Use Atom
- Bottom-up composition: State is built from small, independent pieces
- Dynamic dependency graphs: Dependencies change based on values
- React Suspense: Async atoms integrate natively with Suspense
- Code splitting: Atoms can be defined anywhere and composed lazily
- Fine-grained reactivity: Each atom triggers updates only in its subscribers
Atom vs Store
| Atom | Store |
|---|---|
| Many small state pieces | One state object |
| Bottom-up composition | Top-down definition |
| Dynamic dependencies | Static state shape |
| Suspense-friendly | Middleware-friendly |
| Distributed across modules | Co-located state + actions |