Atom Design
Low-level design for @stateloom/atom — the Jotai-like atomic state paradigm adapter. Covers atom config objects, scope-based lazy signal creation, derived atoms, writable atoms, atom families, and the internal registry.
Overview
The atom paradigm models state as a graph of independent atoms. Unlike signals, atoms are config objects — they describe how to create or derive a value, but their actual state lives in an AtomScope. This separation enables SSR isolation (one scope per request) and testing (one scope per test) without modifying atom definitions.
Atom Config Objects
Atoms created by atom(), derived(), and writableAtom() are config objects registered in a module-level WeakMap called the config registry. The atom object itself holds no state — it serves as a key for scope lookups.
Internal Config Types
const ATOM_KIND = {
BASE: 'base',
DERIVED: 'derived',
WRITABLE: 'writable',
} as const;
interface BaseAtomConfig {
readonly kind: 'base';
readonly init: unknown;
}
interface DerivedAtomConfig {
readonly kind: 'derived';
readonly read: (get: AtomGetter) => unknown;
}
interface WritableAtomInternalConfig {
readonly kind: 'writable';
readonly read: ((get: AtomGetter) => unknown) | null;
readonly write: (get: AtomGetter, set: AtomSetter, ...args: unknown[]) => unknown;
}Config Registry
const configRegistry = new WeakMap<object, InternalAtomConfig>();
function registerAtom(atom: object, config: InternalAtomConfig): void {
configRegistry.set(atom, config);
}
function lookupConfig(atom: object): InternalAtomConfig {
const config = configRegistry.get(atom);
if (!config) throw new Error('Invalid atom');
return config;
}The WeakMap ensures atoms can be garbage collected when no longer referenced. The registry is the single source of truth for atom metadata — the AtomScope reads it during lazy signal creation.
AtomScope
AtomScope is the value container for atoms. It lazily creates core signal or computed instances for each atom on first access, caching them in a WeakMap<object, Subscribable<unknown>>.
Lazy Signal Creation
When an atom is first accessed in a scope, the scope creates the appropriate core primitive:
- Base atoms:
signal(config.init)— a writable signal holding the initial value - Derived atoms:
computed(() => config.read(getter))— a core computed that calls the read function with a scope-bound getter - Writable atoms with read:
computed(() => config.read(getter))— same as derived for the read side - Write-only atoms (
read: null):signal(null)— value is always null
The Getter Function
The AtomGetter passed to derived and writable atom read functions resolves atoms recursively through the scope:
#makeGetter(): AtomGetter {
return <V>(a: Subscribable<V>): V =>
this.resolve(a as AnyReadableAtom<V>).get();
}When a derived atom calls get(otherAtom), it:
- Resolves
otherAtomto its underlyingSubscribablein the same scope - Calls
.get()on it, which (inside acomputed) registers a dependency in the core reactive graph
This means derived atom dependencies are auto-tracked by the core's dependency graph, exactly like computed(() => signal.get()).
Subscription via Effect
Atom subscriptions use core effect() rather than the raw subscribe() method. This is because core's computed.subscribe() only fires when .get() is called after a change (lazy), but atom consumers expect eager notification:
sub<T>(atom: AnyReadableAtom<T>, callback: (value: T) => void): () => void {
const subscribable = this.resolve(atom);
let prevValue: T | undefined;
let isFirst = true;
return effect(() => {
const value = subscribable.get();
if (isFirst) {
isFirst = false;
prevValue = value;
return undefined;
}
if (!Object.is(prevValue, value)) {
prevValue = value;
callback(value);
}
return undefined;
});
}The effect eagerly tracks the subscribable and calls the callback on each change, skipping the initial value (matching the convention that subscribe only fires on changes, not the initial value).
Derived Atoms
Derived atoms are read-only atoms whose value is computed from other atoms:
const doubledAtom = derived((get) => get(countAtom) * 2);Internally, DerivedImpl registers a DERIVED config and delegates all operations to the default scope:
get()->getDefaultScope().get(this)-> resolves to acomputed, calls.get()subscribe(cb)->getDefaultScope().sub(this, cb)-> creates an effect that tracks the computed
Dependency Graph
When the scope resolves a derived atom, it creates:
computed(() => config.read(getter));The getter function resolves dependencies through the same scope. If read calls get(atomA) and get(atomB), the resulting core computed has dependency links to the signals/computed created for atomA and atomB in this scope. The core graph handles all dirty propagation and lazy recomputation.
Writable Atoms
Writable atoms combine a read derivation with a custom write function:
const fahrenheitAtom = writableAtom(
(get) => (get(celsiusAtom) * 9) / 5 + 32, // read
(get, set, fahrenheit: number) => {
// write
set(celsiusAtom, ((fahrenheit - 32) * 5) / 9);
},
);The write() method creates a getter/setter pair bound to the current scope and invokes the write function:
write(...args: Args): Result {
const scope = getDefaultScope();
const { get, set } = scope.makeGetterSetter();
return this.#writeFn(get, set, ...args);
}The setter function wraps each write in a batch():
makeSetter(): AtomSetter {
return <V>(atom: Atom<V>, value: V): void => {
batch(() => { this.resolveSignal(atom).set(value); });
};
}This ensures that multiple atom writes within a single write() call are batched together.
Atom Families
atomFamily creates a memoized factory for parameterized atoms:
const todoAtom = atomFamily((id: string) => atom<Todo>({ id, text: '', done: false }));
todoAtom('todo-1') === todoAtom('todo-1'); // true — memoizedThe implementation is a Map<Param, Result> with a remove() method:
function atomFamily<Param, Result>(factory: (param: Param) => Result) {
const cache = new Map<Param, Result>();
const get = (param: Param): Result => {
const existing = cache.get(param);
if (existing !== undefined) return existing;
const result = factory(param);
cache.set(param, result);
return result;
};
get.remove = (param: Param): boolean => cache.delete(param);
return get;
}Parameters are compared with SameValueZero (Map's default). For object parameters, reference identity is used — use string keys for stable lookups.
WARNING
Atoms are cached indefinitely. For dynamic collections, call atomFamily.remove(param) to evict entries when items are removed.
Default Scope
A module-level default scope is created lazily on first access:
let defaultScope: AtomScopeImpl | undefined;
function getDefaultScope(): AtomScopeImpl {
defaultScope ??= new AtomScopeImpl();
return defaultScope;
}All convenience methods on atom objects (get(), set(), subscribe()) delegate to this default scope. For SSR, consumers create explicit scopes via createAtomScope().
Design Decisions
Why Atoms Are Config Objects (Not Value Containers)
If atoms held their values directly, SSR would require creating new atom instances per request. By separating config from state, the same atom definitions can be shared across requests while each request gets its own scope with independent values.
Why WeakMap for Signal Cache
WeakMap<object, Subscribable> means when an atom is garbage collected (no more references), its scope-level signal is also eligible for collection. This prevents memory leaks in atom families where atoms are dynamically created and removed.
Why Effect-Based Subscriptions
Core's computed.subscribe() is lazy — it only notifies when the computed is read after a change. Atom consumers expect eager notifications (callback fires when any upstream changes). Using effect() bridges this gap: the effect eagerly tracks the computed and pushes changes to the callback.
Why Batch in AtomSetter
Writable atom write functions may call set() on multiple atoms. Without batching, each set() would trigger immediate propagation. The batch ensures all writes are coalesced, and dependents see a consistent state.
Atom Recomputation Flow
When a base atom changes, derived atoms recompute through the core graph. This sequence shows the full flow:
Atom Family Lifecycle
Atom families use a Map for memoization. This state diagram shows the lifecycle of family entries:
WARNING
Atom family entries are cached indefinitely by their parameter key. For dynamic collections (e.g., todo items), call family.remove(id) when items are deleted to prevent memory leaks.
Performance Considerations
| Concern | Strategy | Complexity |
|---|---|---|
| Lazy creation | Signals/computed created on first access, not on atom definition | O(1) per atom resolution |
| Cache hit | WeakMap lookup is O(1) for subsequent accesses | O(1) |
| Memory | WeakMap allows GC of unused atom signals; no global registry leak | Automatic via GC |
| Scope isolation | Each scope has its own WeakMap — zero cross-scope interference | O(1) per scope |
| Batch optimization | Setter wraps in batch — multiple writes in one write() coalesce | O(1) per batch |
| Atom family | Map-based memoization; SameValueZero comparison for keys | O(1) lookup, unbounded growth |
Memory Patterns
| Construct | Per-Instance Allocation | When GC-Eligible |
|---|---|---|
atom(value) config | 1 config object in WeakMap | When no references to atom object |
| Resolved signal in scope | 1 core signal in scope's WeakMap | When atom is GC'd (WeakMap key) |
derived config | 1 config object in WeakMap | When no references to derived object |
| Resolved computed in scope | 1 core computed in scope's WeakMap | When derived atom is GC'd |
atomFamily cache | 1 Map entry per unique parameter | Only via explicit remove(param) |
Cross-References
- Architecture Overview — where atom fits in the layer structure
- Core Design — signal/computed/effect that atoms delegate to
- Design Philosophy — config-vs-value separation rationale
- Adapters Overview — how
useAtomhooks bridge to frameworks - API Reference:
@stateloom/atom— consumer-facing documentation