Introduction to State Management in Deno
State management is one of the most critical aspects of building robust server-side applications. In Deno, the modern TypeScript runtime created by Ryan Dahl, state management involves handling data that persists across requests, sessions, and even server restarts. Whether you are building a REST API, a real-time WebSocket server, or a full-stack web application, how you manage state directly impacts performance, scalability, and maintainability.
Deno's secure-by-default architecture, its standard library, and its growing ecosystem of third-party modules offer several approaches to state management. This tutorial explores the core concepts, common patterns, and popular libraries you can use to manage state effectively in Deno applications.
What Is State Management?
In the context of a Deno application, state refers to any data that the server needs to remember between operations. This can include:
- Application state: Configuration values, feature flags, or cached data shared across the entire application.
- Request state: Data scoped to a single HTTP request, such as authenticated user information.
- Session state: User-specific data that persists across multiple requests, like shopping cart contents.
- Persistent state: Data stored in databases or files that survives server restarts.
State management is the set of techniques and tools used to store, access, update, and invalidate this data in a predictable and efficient manner.
Why State Management Matters in Deno
Deno applications, like any server-side system, are inherently concurrent. Multiple requests arrive simultaneously, each potentially reading and modifying shared state. Without a deliberate state management strategy, you risk race conditions, memory leaks, inconsistent data, and difficult-to-debug errors.
Proper state management provides several benefits:
- Predictability: Clear ownership of data makes application behavior easier to reason about.
- Testability: Decoupled state containers are simpler to mock and test in isolation.
- Scalability: Well-structured state can be moved from in-memory storage to external caches or databases with minimal refactoring.
- Security: Proper scoping prevents data leakage between users and requests.
Pattern 1: In-Memory State with Modules
The simplest form of state management in Deno leverages JavaScript's module system. Because ES modules are singletons within a process, exporting an object or class from a module creates a shared instance accessible anywhere that imports it.
Basic In-Memory Store
Here is a minimal example of an in-memory key-value store:
// state.ts
interface Store {
[key: string]: unknown;
}
const state: Store = {};
export function getState(key: string): unknown {
return state[key];
}
export function setState(key: string, value: unknown): void {
state[key] = value;
}
export function deleteState(key: string): void {
delete state[key];
}
export function clearState(): void {
for (const key of Object.keys(state)) {
delete state[key];
}
}
You can use this store in an Oak or native Deno server:
// app.ts
import { getState, setState } from "./state.ts";
function handler(req: Request): Response {
const url = new URL(req.url);
if (url.pathname === "/visit") {
const visits = (getState("visits") as number ?? 0) + 1;
setState("visits", visits);
return new Response(`Total visits: ${visits}`, { status: 200 });
}
return new Response("Not Found", { status: 404 });
}
Deno.serve({ port: 8000 }, handler);
Run the server with:
deno run --allow-net app.ts
This approach is fast and simple, but it has limitations. Data is lost when the process restarts, and it does not scale across multiple server instances. Use it only for ephemeral data like rate-limiting counters or development caches.
Pattern 2: Class-Based State Container
For more structured applications, a class-based state container provides encapsulation, type safety, and the ability to subscribe to changes. This pattern resembles lightweight versions of state management libraries used in front-end development.
// store.ts
type Listener<T> = (state: T) => void;
export class Store<T> {
private state: T;
private listeners: Set<Listener<T>> = new Set();
constructor(initialState: T) {
this.state = initialState;
}
getState(): T {
return this.state;
}
setState(updater: Partial<T> | ((prev: T) => Partial<T>)): void {
const update = typeof updater === "function"
? updater(this.state)
: updater;
this.state = { ...this.state, ...update };
this.notify();
}
subscribe(listener: Listener<T>): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notify(): void {
for (const listener of this.listeners) {
listener(this.state);
}
}
}
Here is how you might use this store in an application:
// app_state.ts
import { Store } from "./store.ts";
interface AppState {
userCount: number;
activeSessions: Record<string, { userId: string; lastActive: number }>;
featureFlags: Record<string, boolean>;
}
const initialState: AppState = {
userCount: 0,
activeSessions: {},
featureFlags: {
newDashboard: false,
betaFeatures: true,
},
};
export const appStore = new Store<AppState>(initialState);
// Subscribe to changes
appStore.subscribe((state) => {
console.log("State updated:", state.userCount, "users");
});
// Update state
appStore.setState({ userCount: 1 });
appStore.setState((prev) => ({
activeSessions: {
...prev.activeSessions,
"session-abc": { userId: "user-123", lastActive: Date.now() },
},
}));
Pattern 3: Request-Scoped State with Context
Many Deno web frameworks support a context object that carries request-scoped state. This is essential for avoiding data leakage between concurrent requests. The popular Oak framework provides a ctx.state object for this purpose.
// oak_app.ts
import { Application, Router } from "https://deno.land/x/oak@v12.6.1/mod.ts";
interface User {
id: string;
name: string;
roles: string[];
}
const app = new Application();
const router = new Router();
// Middleware to attach request-scoped state
router.use(async (ctx, next) => {
ctx.state.requestId = crypto.randomUUID();
ctx.state.startTime = Date.now();
ctx.state.user = null as User | null;
await next();
});
// Authentication middleware
router.use(async (ctx, next) => {
const authHeader = ctx.request.headers.get("authorization");
if (authHeader) {
// Simulate user lookup
ctx.state.user = {
id: "user-123",
name: "Alice",
roles: ["admin"],
};
}
await next();
});
router.get("/profile", (ctx) => {
const user = ctx.state.user as User | null;
if (!user) {
ctx.response.status = 401;
ctx.response.body = { error: "Unauthorized" };
return;
}
ctx.response.body = {
requestId: ctx.state.requestId,
user,
durationMs: Date.now() - ctx.state.startTime,
};
});
app.use(router.routes());
app.use(router.allowedMethods());
console.log("Server running on http://localhost:8000");
await app.listen({ port: 8000 });
Using ctx.state ensures that each request has its own isolated state object. Data attached to one request is never visible to another, which is critical for security and correctness.
Pattern 4: Session State with KV Storage
Deno KV is a built-in key-value database available in Deno Deploy and locally with the --unstable-kv flag. It provides a simple, transactional API for persistent state that works both in development and production.
// kv_session.ts
interface SessionData {
userId: string;
createdAt: number;
cart: string[];
}
const kv = await Deno.openKv();
export async function createSession(sessionId: string, data: SessionData): Promise<void> {
await kv.set(["sessions", sessionId], data, {
expireInMs: 1000 * 60 * 60 * 24, // 24 hours
});
}
export async function getSession(sessionId: string): Promise<SessionData | null> {
const result = await kv.get<SessionData>(["sessions", sessionId]);
return result.value;
}
export async function updateCart(sessionId: string, itemId: string): Promise<void> {
const session = await getSession(sessionId);
if (!session) throw new Error("Session not found");
// Use atomic transaction for safe concurrent updates
await kv.atomic()
.check({ key: ["sessions", sessionId], versionstamp: (await kv.get(["sessions", sessionId])).versionstamp })
.set(["sessions", sessionId], {
...session,
cart: [...session.cart, itemId],
})
.commit();
}
export async function deleteSession(sessionId: string): Promise<void> {
await kv.delete(["sessions", sessionId]);
}
Use these functions in a server handler:
// server.ts
import { createSession, getSession, updateCart } from "./kv_session.ts";
Deno.serve({ port: 8000 }, async (req) => {
const url = new URL(req.url);
if (url.pathname === "/login" && req.method === "POST") {
const sessionId = crypto.randomUUID();
await createSession(sessionId, {
userId: "user-123",
createdAt: Date.now(),
cart: [],
});
return new Response(JSON.stringify({ sessionId }), {
headers: { "Content-Type": "application/json" },
});
}
if (url.pathname === "/cart" && req.method === "POST") {
const sessionId = req.headers.get("x-session-id");
if (!sessionId) return new Response("Missing session", { status: 400 });
const body = await req.json();
await updateCart(sessionId, body.itemId);
const session = await getSession(sessionId);
return new Response(JSON.stringify(session), {
headers: { "Content-Type": "application/json" },
});
}
return new Response("Not Found", { status: 404 });
});
Run with:
deno run --unstable-kv --allow-net server.ts
Pattern 5: Event-Driven State with EventEmitter
For applications that need to react to state changes, such as real-time dashboards or WebSocket servers, combining a state container with Deno's built-in EventEmitter pattern is highly effective.
// event_store.ts
import { Store } from "./store.ts";
interface ChatState {
messages: { id: string; user: string; text: string; timestamp: number }[];
onlineUsers: string[];
}
const chatStore = new Store<ChatState>({
messages: [],
onlineUsers: [],
});
// WebSocket connections registry
const sockets = new Set<WebSocket>();
export function addSocket(ws: WebSocket): void {
sockets.add(ws);
ws.onclose = () => sockets.delete(ws);
}
export function broadcastMessage(user: string, text: string): void {
const message = {
id: crypto.randomUUID(),
user,
text,
timestamp: Date.now(),
};
chatStore.setState((prev) => ({
messages: [...prev.messages.slice(-99), message],
}));
for (const ws of sockets) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "message", data: message }));
}
}
}
export function getMessages() {
return chatStore.getState().messages;
}
Libraries for State Management in Deno
Deno KV
As shown above, Deno KV is the first-party solution for persistent, transactional state. It is ideal for session storage, caching, and simple data models. It supports atomic operations, secondary indexes through key prefixes, and automatic TTL expiration.
Oak Middleware State
Oak's middleware architecture naturally supports layered state management. Each middleware can enrich the context state, making it easy to build pipelines for authentication, logging, and feature flags.
Fresh and Island Architecture
Fresh is Deno's full-stack web framework. It uses Preact for rendering and manages state through a combination of server-side props and client-side signals. For client state, Fresh recommends the useSignal hook from Preact.
// islands/counter.tsx (Fresh island)
import { useSignal } from "https://esm.sh/@preact/signals@1.2.0";
export default function Counter() {
const count = useSignal(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => count.value++}>Increment</button>
<button onClick={() => count.value--}>Decrement</button>
</div>
);
}
Third-Party State Libraries
Because Deno can import npm packages via npm: specifiers, you can use popular state management libraries like Zustand or Nano Stores:
// zustand_example.ts
import { create } from "npm:zustand@4.4.7";
interface CounterState {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
}
const useCounter = create<CounterState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}));
// Usage
console.log(useCounter.getState().count); // 0
useCounter.getState().increment();
console.log(useCounter.getState().count); // 1
useCounter.getState().reset();
console.log(useCounter.getState().count); // 0
Best Practices
Separate Ephemeral and Persistent State
Not all state needs to survive a restart. Keep ephemeral data like rate-limit counters in memory, and reserve database or KV storage for data that must persist. Mixing these concerns leads to unnecessary database load and complexity.
Use Atomic Operations for Concurrent Writes
When multiple requests might update the same state simultaneously, always use atomic operations. Deno KV's atomic() API and JavaScript's Atomics for shared array buffers help prevent race conditions.
Scope State Appropriately
Never store request-specific data in module-level variables. Always use the framework's context object or pass state explicitly through function parameters. This prevents dangerous cross-request data leakage.
Type Your State Interfaces
Deno's TypeScript-first nature is a major advantage. Always define interfaces for your state objects. This catches bugs at compile time and makes refactoring safer.
// Good: typed state
interface AppState {
users: Map<string, User>;
config: AppConfig;
}
// Avoid: untyped state
const state: any = {};
Implement State Invalidation
Cached state grows stale. Implement TTLs, version checks, or explicit invalidation triggers. Deno KV supports expireInMs natively, and you can build similar mechanisms for in-memory stores.
Avoid Global Mutable State When Possible
Global mutable state makes testing difficult and creates hidden dependencies. Prefer dependency injection, where state containers are passed into handlers and services explicitly.
// Avoid: hidden global dependency
import { appStore } from "./global_store.ts";
function getUserCount() {
return appStore.getState().userCount;
}
// Prefer: explicit dependency
function getUserCount(store: Store<AppState>): number {
return store.getState().userCount;
}
Log State Changes in Development
During development, logging state transitions helps debug issues. You can add a logging subscriber to your store:
if (Deno.env.get("DENO_ENV") === "development") {
appStore.subscribe((state) => {
console.log("[STATE]", JSON.stringify(state, null, 2));
});
}
Conclusion
State management in Deno spans a spectrum from simple in-memory module singletons to persistent, transactional key-value stores. The right approach depends on your application's requirements: use module-level state for ephemeral counters, class-based containers for structured reactive state, context objects for request-scoped data, and Deno KV for persistence that survives restarts. By following patterns like atomic writes, typed interfaces, proper scoping, and dependency injection, you can build Deno applications that are predictable, testable, and ready to scale. As the Deno ecosystem continues to mature, leveraging both first-party tools like Deno KV and battle-tested npm libraries gives you a flexible toolkit for managing state in any server-side scenario.