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 deploySee the CLI reference for deployment options.
See Also
- State & Modules — State management fundamentals
- Modules API — Complete module API reference
- TypeScript SDK — Session management and RemoteServer