โ† Back to DevBytes

State Management in Deno: Patterns and Libraries

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:

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:

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.

๐Ÿ›  Tools from DevBytes

Inventory Tracker Pro โ€” Excel inventory system, low-stock alerts ยท $19
AI Dev Kit for Mac โ€” local AI dev environment templates ยท $9.99
KeyMapper for Mac โ€” custom keyboard shortcut toolkit ยท $7.99

โ† Back to all articles