Atom Design

February 13, 2026 · View on GitHub

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.

graph TB
    subgraph "Atom Config Objects"
        base["atom(0)<br/>kind: BASE, init: 0"]
        derived_a["derived(get => ...)<br/>kind: DERIVED, read: fn"]
        writable_a["writableAtom(read, write)<br/>kind: WRITABLE"]
        family["atomFamily(factory)<br/>Map-based memoization"]
    end

    subgraph "AtomScope (value container)"
        scope["WeakMap&lt;atom, Signal | Computed&gt;"]
        resolve["resolve(atom): Subscribable"]
    end

    subgraph "@stateloom/core"
        sig["signal(value)"]
        comp["computed(fn)"]
        eff["effect(fn)"]
    end

    base --> scope
    derived_a --> scope
    writable_a --> scope
    scope --> sig
    scope --> comp
    resolve --> eff

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>>.

flowchart TB
    A["scope.resolve(atom)"] --> B{Already in WeakMap?}
    B -->|Yes| C["Return cached Subscribable"]
    B -->|No| D{Is registered atom?}
    D -->|No| E["Return as raw Subscribable (interop)"]
    D -->|Yes| F{Config kind?}
    F -->|BASE| G["signal(config.init)"]
    F -->|DERIVED| H["computed(() => config.read(getter))"]
    F -->|WRITABLE + read| I["computed(() => read(getter))"]
    F -->|WRITABLE + null| J["signal(null)"]
    G --> K["Cache in WeakMap"]
    H --> K
    I --> K
    J --> K
    K --> L["Return Subscribable"]

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:

  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:

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

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.

graph LR
    subgraph "Atom Layer"
        countAtom["countAtom (config)"]
        doubledAtom["doubledAtom (config)"]
    end

    subgraph "Scope (core primitives)"
        countSig["signal(0)"]
        doubledComp["computed(() => get(count) * 2)"]
    end

    countAtom -.->|resolves to| countSig
    doubledAtom -.->|resolves to| doubledComp
    countSig -->|dependency link| doubledComp

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 — memoized

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

sequenceDiagram
    participant App
    participant Base as Base Atom (countAtom)
    participant Scope as AtomScope
    participant Signal as signal(0)
    participant Comp as computed (doubled)
    participant Effect as effect (subscription)
    participant CB as Subscriber Callback

    App->>Base: set(5)
    Base->>Scope: resolve(countAtom)
    Scope->>Signal: signal.set(5)
    Signal->>Comp: propagateChange: mark DIRTY
    Comp->>Effect: propagateMaybeDirty
    Effect->>Effect: scheduleEffect
    Note over Effect: Effect flush begins
    Effect->>Comp: computed.get() [pull]
    Comp->>Signal: signal.get() [check version]
    Comp->>Comp: Recompute: 5 * 2 = 10
    Comp->>Effect: Return 10
    Effect->>CB: callback(10)

Atom Family Lifecycle

Atom families use a Map for memoization. This state diagram shows the lifecycle of family entries:

stateDiagram-v2
    [*] --> Empty: atomFamily(factory)
    Empty --> Cached: get(param) — cache miss
    Cached --> Cached: get(param) — cache hit
    Cached --> Removed: remove(param)
    Removed --> Cached: get(param) — re-create
    Removed --> Empty: All entries removed

    state Cached {
        [*] --> InMap: factory(param) called once
        InMap --> InMap: Subsequent get(param) returns same
        InMap --> [*]: remove(param) deletes entry
    }

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