HypenHypen
Guide

State Persistence

Automatically save and restore module state across sessions with pluggable storage adapters

State Persistence

Modules can persist their state across sessions, reconnections, and server restarts. Add .persist() to your module definition and the runtime handles save/restore automatically.

Quick Start

import { app } from "@hypen-space/core";
import { durableObjectStore, session } from "@hypen-space/cf";

export default app
  .defineState({ count: 0 })
  .persist(durableObjectStore(session()))
  .onAction("increment", async ({ state }) => {
    state.count++;
  })
  .build();

That's it. The counter's value survives page reloads, reconnections, and server restarts.

Key Strategies

Every persisted module needs a key strategy that determines how state is scoped. There are three options:

session() — Per-Session

Each browser session gets its own state. State is tied to an auto-generated session ID.

import { durableObjectStore, session } from "@hypen-space/cf";

app
  .defineState({ draft: "" })
  .persist(durableObjectStore(session()))
  .build();

Use for: form drafts, in-progress work, temporary user data.

global() — Shared by Everyone

One state instance shared across all users.

import { durableObjectStore, global } from "@hypen-space/cf";

app
  .defineState({ messages: [], onlineCount: 0 })
  .persist(durableObjectStore(global()))
  .build();

Use for: chat rooms, shared counters, collaborative state.

withKey(fn) — Derived from State

The storage key is computed from the current state. This enables per-user persistence where the user identity isn't known until after login.

import { durableObjectStore, withKey } from "@hypen-space/cf";

app
  .defineState({ user: null, todos: [] })
  .persist(durableObjectStore(withKey(state => state.user?.id)))
  .onAction("login", async ({ state }) => {
    const user = await authenticate();
    state.user = user;
    // Persistence activates now — todos are loaded from storage
  })
  .onAction("logout", async ({ state }) => {
    state.user = null;
    // Persistence deactivates — stored data is retained for next login
  })
  .build();

The key function is re-evaluated on every state change:

  • Returns null → no persistence (e.g., not logged in)
  • Returns a value → state is saved/restored for that key
  • Value changes → switches to a different stored state (e.g., account switch)

How State Restore Works

When a persisted module loads, stored state is merged over the initial state:

// Your definition
app.defineState({ count: 0, label: "My Counter" })

// Stored state (from previous session)
{ count: 5 }

// Result after restore
{ count: 5, label: "My Counter" }

This means you can safely add new fields to your state — they'll get their default values from defineState while existing fields are restored from storage.

State is restored before onCreated runs, so your lifecycle handler always sees the restored state:

.onCreated(async (state) => {
  console.log(state.count); // 5 (restored), not 0 (initial)
})

Multi-Module Apps

Each module defines its own persistence strategy independently. Modules are flat peers — there's no inheritance:

// User profile — per-user persistence
app
  .defineState({ user: null, preferences: {} }, { name: "Profile" })
  .persist(durableObjectStore(withKey(state => state.user?.id)))
  .build();

// Chat — shared by everyone
app
  .defineState({ messages: [] }, { name: "Chat" })
  .persist(durableObjectStore(global()))
  .build();

// Settings modal — ephemeral, no persistence
app
  .defineState({ isOpen: false }, { name: "Settings" })
  .build();

Writing a Custom Store

The persistence layer is adapter-agnostic. If you're not deploying to Cloudflare, implement the StateStore interface:

import type { StateStore } from "@hypen-space/core";

function redisStore<T>(redisClient: RedisClient): StateStore<T> {
  return {
    resolveKey(state, moduleName, sessionId) {
      return `${moduleName}:${sessionId}`;
    },
    async load(key) {
      const data = await redisClient.get(key);
      return data ? JSON.parse(data) : null;
    },
    async save(key, state) {
      await redisClient.set(key, JSON.stringify(state));
    },
    async delete(key) {
      await redisClient.del(key);
    },
  };
}

// Usage
app
  .defineState({ count: 0 })
  .persist(redisStore(redis))
  .build();

StateStore Interface

interface StateStore<T = unknown> {
  /** Resolve the storage key. Returns null to disable persistence. */
  resolveKey(state: T, moduleName: string, sessionId: string): string | null;

  /** Load persisted state. Returns null if no stored state exists. */
  load(key: string): Promise<T | null>;

  /** Save state snapshot. Called on mutation (debounced by runtime). */
  save(key: string, state: T): Promise<void>;

  /** Delete persisted state. Called on session expiry. */
  delete(key: string): Promise<void>;
}

Deployment

When deploying to Cloudflare with hypen deploy, the CLI automatically provisions Durable Objects for modules that use .persist(). No manual configuration needed.

hypen deploy

See the CLI reference for deployment options.

See Also