Skip to content

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

typescript
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

typescript
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:

typescript
#makeGetter(): AtomGetter {
  return <V>(a: Subscribable<V>): V =>
    this.resolve(a as AnyReadableAtom<V>).get();
}

When a derived atom calls get(otherAtom), it:

  1. Resolves otherAtom to its underlying Subscribable in the same scope
  2. Calls .get() on it, which (inside a computed) 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:

typescript
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:

typescript
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 a computed, 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:

typescript
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:

typescript
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:

typescript
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():

typescript
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:

typescript
const todoAtom = atomFamily((id: string) => atom<Todo>({ id, text: '', done: false }));
todoAtom('todo-1') === todoAtom('todo-1'); // true — memoized

The implementation is a Map<Param, Result> with a remove() method:

typescript
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:

typescript
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

ConcernStrategyComplexity
Lazy creationSignals/computed created on first access, not on atom definitionO(1) per atom resolution
Cache hitWeakMap lookup is O(1) for subsequent accessesO(1)
MemoryWeakMap allows GC of unused atom signals; no global registry leakAutomatic via GC
Scope isolationEach scope has its own WeakMap — zero cross-scope interferenceO(1) per scope
Batch optimizationSetter wraps in batch — multiple writes in one write() coalesceO(1) per batch
Atom familyMap-based memoization; SameValueZero comparison for keysO(1) lookup, unbounded growth

Memory Patterns

ConstructPer-Instance AllocationWhen GC-Eligible
atom(value) config1 config object in WeakMapWhen no references to atom object
Resolved signal in scope1 core signal in scope's WeakMapWhen atom is GC'd (WeakMap key)
derived config1 config object in WeakMapWhen no references to derived object
Resolved computed in scope1 core computed in scope's WeakMapWhen derived atom is GC'd
atomFamily cache1 Map entry per unique parameterOnly via explicit remove(param)

Cross-References