Skip to content

@stateloom/atom

Bottom-up atomic state composition. If you've used Jotai, this API will feel immediately familiar.

Install

bash
pnpm add @stateloom/core @stateloom/atom
bash
npm install @stateloom/core @stateloom/atom
bash
yarn add @stateloom/core @stateloom/atom

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

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

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

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

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

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

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

ParameterTypeDescriptionDefault
initialValueTThe initial value of the atom.--

Returns: Atom<T> -- a writable atom with get(), set(), update(), and subscribe() methods.

typescript
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); // 6

Key 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 call scope.get(atom) / scope.set(atom, value)
  • Equality is checked with Object.is by 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:

ParameterTypeDescriptionDefault
read(get: AtomGetter) => TDerivation function. Receives get to read dependencies. May be async.--

Returns: ReadonlyAtom<T> -- a read-only atom whose value is derived from read.

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

ParameterTypeDescriptionDefault
read((get: AtomGetter) => Value) | nullRead derivation, or null for write-only atoms.--
write(get: AtomGetter, set: AtomSetter, ...args: Args) => ResultCustom write function.--

Returns: WritableAtom<Value, Args, Result> -- a derived atom with a write() method.

typescript
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(); // 100

Key behaviors:

  • Write-only atoms (read: null) always return null from get()
  • The set function only accepts base atoms created by atom() -- to trigger another writable atom's write, call its write() method directly
  • Multiple set() calls within a single write() are batched together
  • The write function executes synchronously

See also: atom(), derived()


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:

ParameterTypeDescriptionDefault
factory(param: Param) => ResultFunction that creates an atom for a given parameter.--

Returns: A memoized factory function with a remove(param) method for cache eviction.

typescript
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 as Map.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.

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

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

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

typescript
type ReadonlyAtom<T> = Subscribable<T>;

WritableAtom<Value, Args, Result> (interface)

A derived atom with custom write logic. Extends Subscribable<Value>.

typescript
interface WritableAtom<Value, Args extends unknown[], Result> extends Subscribable<Value> {
  write(...args: Args): Result;
}

AtomScope (interface)

An isolated container for atom values.

typescript
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;
}
MethodDescription
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.

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

typescript
type AtomSetter = <V>(atom: Atom<V>, value: V) => void;

AnyReadableAtom<T>

Union type of all atoms whose value can be read.

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

typescript
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

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

Storage Sync

typescript
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 KindCore Primitive Created
BASEsignal(config.init)
DERIVEDcomputed(() => 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:

  1. Base atom value changes -- underlying signal's version increments
  2. All derived atoms that read it are marked stale (MAYBE_DIRTY)
  3. On next read, stale derived atoms recompute
  4. 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:

typescript
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

AtomStore
Many small state piecesOne state object
Bottom-up compositionTop-down definition
Dynamic dependenciesStatic state shape
Suspense-friendlyMiddleware-friendly
Distributed across modulesCo-located state + actions