alepha@docs:~/docs/guides/frontend$
cat 3-state-management.md | pretty
7 min read
Last commit:

#State Management

Alepha provides $atom for defining typed, validated state and useStore for consuming it in React components. State is a simple key-value store with schema validation, SSR hydration, and event-driven updates.

#Defining Atoms

Atoms are defined at the module level using $atom. Each atom has a unique name, a TypeBox schema, and an optional default value.

typescript
1import { $atom, z } from "alepha";2 3const counter = $atom({4  name: "app:counter",5  schema: z.object({6    count: z.number().int(),7  }),8  default: { count: 0 },9});

Atoms must contain only serializable data. Avoid storing class instances, functions, or DOM elements.

Options:

Option Type Description
name string Unique identifier for the atom.
schema ZodType Zod schema for validation.
default Static<T> Default value. Required unless schema is fully optional.
description string Optional description for documentation.

#useStore Hook

The useStore hook connects React components to Alepha state. It returns a [value, setValue] tuple, similar to useState.

typescript
 1import { useStore } from "alepha/react"; 2  3function Counter() { 4  const [state, setState] = useStore(counter); 5  6  return ( 7    <button onClick={() => setState({ count: state.count + 1 })}> 8      Count: {state.count} 9    </button>10  );11}

When the state changes (from any source), the component re-renders automatically.

#Using with Atom

typescript
1const [value, setValue] = useStore(counter);

#Using with Store Keys

You can also access state by string key, without an atom:

typescript
1const [lang, setLang] = useStore("alepha.react.i18n.lang");

This is used internally by Alepha modules (e.g., i18n, router) for framework-level state.

#Default Values

Pass a default value as the second argument. It is applied only if the current value is null or undefined:

typescript
1const [prefs, setPrefs] = useStore(userPrefs, { theme: "light" });

#Selecting Slices with useSelector

useStore re-renders whenever any part of the atom mutates, even fields the component never reads. For an atom with several independent fields, that means unrelated updates trigger renders you don't need. useSelector subscribes to a derived slice instead, and only re-renders when that slice actually changes.

typescript
 1import { $atom, z } from "alepha"; 2import { useSelector } from "alepha/react"; 3  4const prefs = $atom({ 5  name: "app:prefs", 6  schema: z.object({ 7    theme: z.string(), 8    sidebar: z.object({ collapsed: z.boolean() }), 9  }),10  default: { theme: "light", sidebar: { collapsed: false } },11});12 13function ThemeLabel() {14  const theme = useSelector(prefs, (s) => s.theme);15  return <span>{theme}</span>;16}

ThemeLabel only re-renders when theme changes. Toggling sidebar.collapsed elsewhere in the app leaves it alone -- useStore(prefs) would re-render on both.

By default, the selected value is compared with Object.is. If your selector builds a new object or array on every call, Object.is never considers two calls equal, so the component would re-render on every mutation regardless of whether the slice actually changed. Pass shallowEqual as the third argument to compare the result key by key instead:

typescript
 1import { useSelector, shallowEqual } from "alepha/react"; 2  3function Sidebar() { 4  const sidebar = useSelector( 5    prefs, 6    (s) => ({ collapsed: s.sidebar.collapsed }), 7    shallowEqual, 8  ); 9  // ...10}

When to use which:

  • useStore(atom) returns [value, setValue] -- use it when a component reads and writes the whole atom, or genuinely needs every field.
  • useSelector(atom, select, equality?) is read-only and re-renders only on changes to the selected slice -- use it for components that only care about part of a larger, frequently-changing atom. To write, call alepha.store.set(atom, ...) or use useStore elsewhere in the tree.

#Non-React Access

Outside of React components, use alepha.store directly:

typescript
1// Read2const value = alepha.store.get(counter);3 4// Write5alepha.store.set(counter, { count: 42 });

Setting a value triggers the state:mutate event, which causes any useStore subscribers to re-render.

#Validation

Atom writes are validated against the schema you declared. An invalid write throws immediately, and unknown keys are silently stripped from the value that gets stored:

typescript
 1const settings = $atom({ 2  name: "app:settings", 3  schema: z.object({ theme: z.string(), count: z.number() }), 4  default: { theme: "light", count: 0 }, 5}); 6  7alepha.store.set(settings, { theme: "dark", count: "nope" } as any); 8// throws -- count must be a number 9 10alepha.store.set(settings, { theme: "dark", count: 1, extra: true } as any);11alepha.store.get(settings); // { theme: "dark", count: 1 } -- `extra` is gone

This applies to every write path -- useStore's setter, alepha.store.set, and raw string-key writes once the atom has registered.

Validation also runs the other direction, for a value that arrives from outside a normal set call: the SSR hydration payload, or a value passed to Alepha.create(seed). If that value doesn't match the schema, the atom falls back to its declared default (and a warning is logged) instead of storing something invalid.

#SSR Hydration

Atoms participate in SSR automatically. On the server, state is serialized into the HTML response. On the client, the state is hydrated before React renders, so components see the server-set values on first render without flicker.

#Persistence

By default, atom state lives only in memory, for the lifetime of the process (server) or the page (browser). Add persist to keep a value around across requests, reloads, or tabs:

typescript
1const uiPrefs = $atom({2  name: "app:uiPrefs",3  schema: z.object({ theme: z.string() }),4  default: { theme: "light" },5  persist: "cookie",6});
persist value Where it works Notes
"cookie" Server and browser The only SSR-safe option: the server reads the cookie while rendering, so the very first paint already matches the persisted state. Use this for any app that renders on the server.
"localStorage" Browser only Fine for pure SPAs. The server cannot see it; registering such an atom during SSR logs a warning.
"sessionStorage" Browser only Same as localStorage, scoped to the tab.

To make persist: "cookie" work, register the cookies module once, in code shared between your server and browser entry points:

typescript
1import { Alepha } from "alepha";2import { AlephaServerCookies } from "alepha/server/cookies";3 4const alepha = Alepha.create().with(AlephaServerCookies);

alepha/server/cookies resolves to the server adapter or the browser adapter automatically depending on the build target, so this single .with(...) call wires up both sides. localStorage and sessionStorage need no extra module -- they're wired up automatically per atom.

Corrupted or invalid stored values -- a hand-edited cookie, a schema that changed since the value was written, localStorage filled with garbage -- are discarded, and the atom's declared default is used instead. This happens silently; persistence never throws.

Security: persist: "cookie" atoms are unsigned, unencrypted, and can be overwritten by any client-side script or a hand-crafted request -- never persist trust-bearing state (user ids, roles, entitlements) in one. If you need a signed, encrypted, or httpOnly cookie, use the $cookie primitive directly instead of persist.

#Derived State with $computed

Some values aren't state on their own -- they're computed from other state. $computed defines a read-only value derived from one or more atoms (or other computed values), with a static list of dependencies:

typescript
 1import { $atom, $computed, z } from "alepha"; 2  3const cart = $atom({ 4  name: "app:cart", 5  schema: z.object({ 6    items: z.array(z.object({ price: z.number(), quantity: z.number() })), 7  }), 8  default: { items: [] }, 9});10 11const cartTotal = $computed({12  name: "app:cartTotal",13  deps: [cart],14  get: (state) =>15    state.items.reduce((sum, item) => sum + item.price * item.quantity, 0),16});

get receives the current value of each entry in deps, in order, and returns the derived value. Read it like an atom, in a component with useComputed:

typescript
1import { useComputed } from "alepha/react";2 3function CartSummary() {4  const total = useComputed(cartTotal);5  return <p>Total: ${total.toFixed(2)}</p>;6}

or outside of React:

typescript
1alepha.store.get(cartTotal); // number

deps can mix atoms and other Computed values, as long as they don't form a cycle -- a computed that depends on itself, even transitively through another computed, throws an AlephaError instead of recursing forever.

Computed values are read-only: alepha.store.set(cartTotal, ...) throws. Change the atoms it depends on instead. They are also never stored, serialized into the SSR hydration payload, or persisted -- the value is recomputed from its dependencies on every read, which is what keeps it correct for request-scoped state on the server (each request/fork gets its own derived value, never a stale one cached from a different request).

Declare a $computed at module scope, next to the atoms it depends on, the same way you declare atoms. Constructing one inline inside a component body creates a new instance on every render, which forces useComputed to resubscribe unnecessarily.

#Event System

State mutations emit a state:mutate event:

typescript
1alepha.events.on("state:mutate", ({ key, value }) => {2  console.log(`State changed: ${key}`, value);3});

This is how useStore knows when to re-render -- it listens for mutations matching its atom key.

#Utilities

Beyond reading and writing, alepha.store exposes a few helpers for working with atoms outside of a component's render cycle.

reset(atom) restores the atom's declared default value. It's also exposed directly on the alepha instance:

typescript
1alepha.store.set(counter, { value: 99 });2alepha.reset(counter); // same as alepha.store.reset(counter)3alepha.store.get(counter); // { value: 0 }

watch(target, callback) subscribes to an atom, a $computed value, or a raw state key outside of React, and returns an unsubscribe function:

typescript
1const unsubscribe = alepha.store.watch(counter, (value, prevValue) => {2  console.log("counter changed", prevValue, "->", value);3});4 5// later6unsubscribe();

Watching a $computed works the same way -- the callback fires whenever any of its transitive dependencies mutate.

serverOnly: true excludes an atom from the SSR hydration payload, so its value never ships to the browser:

typescript
1const sessionSecret = $atom({2  name: "app:sessionSecret",3  schema: z.object({ token: z.string() }),4  default: { token: "" },5  serverOnly: true,6});

Use it for state that must never leave the server -- internal request-scoped data, secrets touched during rendering. The guarantee reaches further than just the hydration payload: serverOnly also withholds the value from the devtools mutation log and metadata endpoints, so it can't leak through those channels either.

serverOnly cannot be combined with persist -- every persistence adapter targets the browser by definition, so declaring both throws an AlephaError at $atom() call time. Pick one.

#Example: Feature Flags

typescript
 1import { $atom, z } from "alepha"; 2import { useStore } from "alepha/react"; 3  4const featureFlags = $atom({ 5  name: "app:features", 6  schema: z.object({ 7    darkMode: z.boolean(), 8    betaFeatures: z.boolean(), 9  }),10  default: {11    darkMode: false,12    betaFeatures: false,13  },14});15 16function FeatureToggle() {17  const [flags, setFlags] = useStore(featureFlags);18 19  return (20    <label>21      <input22        type="checkbox"23        checked={flags.darkMode}24        onChange={() => setFlags({ ...flags, darkMode: !flags.darkMode })}25      />26      Dark Mode27    </label>28  );29}