โ† Back to DevBytes

State Management in Elysia: Patterns and Libraries

State Management in Elysia: Patterns and Libraries

Elysia is a fast and ergonomic web framework built for the Bun runtime. One of its most powerful features is its built-in state management system, which allows developers to share data across routes, plugins, and middleware in a type-safe manner. In this tutorial, we will explore what state management means in Elysia, why it matters, how to implement it effectively, and which patterns and libraries you can leverage to build robust applications.

What Is State Management in Elysia?

State management in Elysia refers to the mechanism by which you store, access, and mutate data that needs to be shared across the lifecycle of a request or across the entire application. Elysia provides a dedicated state method on its instance that lets you define default values, which are then injected into every request's context object. This means you can attach user sessions, database connections, configuration values, or feature flags directly to the app instance and access them inside any handler.

Unlike traditional frameworks where you might rely on global variables or external stores, Elysia's state is deeply integrated with its type system. When you define state, Elysia automatically infers the types and propagates them through your route handlers, plugins, and middleware. This eliminates an entire class of runtime errors caused by accessing undefined properties.

Why State Management Matters

State management is the backbone of any non-trivial web application. Without a structured approach, you end up with scattered variables, duplicated logic, and fragile code. Here are the key reasons why proper state management in Elysia matters:

Getting Started with Elysia State

Let's start with a basic example. First, make sure you have Bun installed, then create a new project and install Elysia:

bun init my-elysia-app
cd my-elysia-app
bun add elysia

Now let's create a simple server that uses state to store a configuration value:

import { Elysia } from 'elysia'

const app = new Elysia()
  .state('version', '1.0.0')
  .state('startTime', Date.now())
  .get('/info', ({ store }) => {
    return {
      version: store.version,
      uptime: Date.now() - store.startTime
    }
  })
  .listen(3000)

console.log('Server running on http://localhost:3000')

In this example, we define two state values: version and startTime. Both are accessible through the store object inside any route handler. The types of these values are automatically inferred, so store.version is typed as string and store.startTime is typed as number.

Defining and Accessing State

The state method accepts a key and a default value. You can chain multiple state calls together, which is the idiomatic way to define state in Elysia. Let's look at a more comprehensive example:

import { Elysia, t } from 'elysia'

interface User {
  id: number
  name: string
  role: 'admin' | 'user'
}

const app = new Elysia()
  .state('db', null as unknown as Map<number, User>)
  .state('requestCount', 0)
  .state('featureFlags', {
    newDashboard: true,
    betaFeatures: false
  })
  .onStart(({ store }) => {
    store.db = new Map()
    console.log('Database initialized')
  })
  .get('/flags', ({ store }) => store.featureFlags)
  .get('/count', ({ store }) => ({ count: store.requestCount }))
  .listen(3000)

Notice how we initialize db as null with a type cast. This is a common pattern when the actual value is set asynchronously during the onStart lifecycle hook. The type cast ensures that downstream code knows the eventual type of the database connection.

Mutating State Per Request

Elysia distinguishes between application-level state and request-level state. Application-level state is shared across all requests, while request-level state is scoped to a single request lifecycle. To create request-scoped state, you use the derive method, which computes values based on the request context:

import { Elysia } from 'elysia'

const app = new Elysia()
  .state('globalCounter', 0)
  .derive(({ headers, store }) => {
    const token = headers.authorization ?? 'anonymous'
    return {
      user: token,
      isAuthenticated: token !== 'anonymous'
    }
  })
  .get('/me', ({ user, isAuthenticated, store }) => {
    store.globalCounter++
    return { user, isAuthenticated, totalRequests: store.globalCounter }
  })
  .listen(3000)

In this example, user and isAuthenticated are derived per request. They are computed fresh for each incoming request and do not persist across requests. Meanwhile, globalCounter is application-level state that increments with every request.

State in Plugins and Middleware

One of Elysia's strengths is its plugin system. Plugins can define their own state, and parent applications can consume or extend that state. This enables modular architecture where each plugin manages its own concerns. Here is an example of a database plugin that exposes a connection through state:

import { Elysia } from 'elysia'

// database-plugin.ts
export const databasePlugin = new Elysia({ name: 'database' })
  .state('db', null as unknown as Map<string, any>)
  .onStart(({ store }) => {
    store.db = new Map()
    console.log('Database plugin initialized')
  })
  .derive(({ store }) => ({
    query: (key: string) => store.db.get(key),
    insert: (key: string, value: any) => store.db.set(key, value)
  }))

// main.ts
const app = new Elysia()
  .use(databasePlugin)
  .post('/items/:key', ({ params, body, insert }) => {
    insert(params.key, body)
    return { success: true }
  })
  .get('/items/:key', ({ params, query }) => {
    const value = query(params.key)
    if (!value) return new Response('Not found', { status: 404 })
    return value
  })
  .listen(3000)

When the databasePlugin is used, its state and derived methods become available to all downstream routes. The type system ensures that query and insert are properly typed wherever they are accessed.

Pattern: Authentication State

A common use case for state management is authentication. You typically want to store the current user in request-scoped state so that every handler can access it without re-parsing the token. Here is a complete pattern:

import { Elysia } from 'elysia'

interface SessionUser {
  id: string
  email: string
  role: 'admin' | 'member'
}

function verifyToken(token: string): SessionUser | null {
  // In production, use a real JWT library
  if (token === 'secret-admin-token') {
    return { id: '1', email: 'admin@example.com', role: 'admin' }
  }
  if (token === 'secret-member-token') {
    return { id: '2', email: 'member@example.com', role: 'member' }
  }
  return null
}

export const authPlugin = new Elysia({ name: 'auth' })
  .derive(({ headers, set }) => {
    const token = headers.authorization?.replace('Bearer ', '')
    if (!token) {
      set.status = 401
      return { user: null }
    }
    const user = verifyToken(token)
    if (!user) {
      set.status = 401
      return { user: null }
    }
    return { user }
  })
  .guard({
    beforeHandle: ({ user, set }) => {
      if (!user) {
        set.status = 401
        return 'Unauthorized'
      }
    }
  })

const app = new Elysia()
  .use(authPlugin)
  .get('/profile', ({ user }) => {
    return user
  })
  .get('/admin', ({ user, set }) => {
    if (user?.role !== 'admin') {
      set.status = 403
      return 'Forbidden'
    }
    return { message: 'Welcome admin' }
  })
  .listen(3000)

This pattern cleanly separates authentication logic into a plugin. The user object is derived per request and is available in every route that uses the plugin. The guard ensures that protected routes automatically reject unauthenticated requests.

Pattern: Configuration and Environment State

Another common pattern is loading configuration from environment variables and storing it in application state. This centralizes configuration access and makes it easy to override during testing:

import { Elysia } from 'elysia'

interface AppConfig {
  port: number
  nodeEnv: 'development' | 'production' | 'test'
  jwtSecret: string
  rateLimit: number
}

function loadConfig(): AppConfig {
  return {
    port: Number(process.env.PORT ?? 3000),
    nodeEnv: (process.env.NODE_ENV ?? 'development') as AppConfig['nodeEnv'],
    jwtSecret: process.env.JWT_SECRET ?? 'dev-secret-change-me',
    rateLimit: Number(process.env.RATE_LIMIT ?? 100)
  }
}

export const configPlugin = new Elysia({ name: 'config' })
  .state('config', loadConfig())

const app = new Elysia()
  .use(configPlugin)
  .get('/health', ({ store }) => ({
    status: 'ok',
    env: store.config.nodeEnv
  }))
  .listen(3000)

By wrapping configuration in a plugin, you ensure that environment variables are read once at startup and are available throughout the application lifecycle.

Using External State Libraries

While Elysia's built-in state management is sufficient for most use cases, you may want to integrate external state libraries for more complex scenarios, such as reactive state or shared state across multiple processes. Here are a few options:

Pattern: Using a Singleton Store

For simple shared state, a singleton module pattern works well. Since Bun uses ES modules, any module-level variable is shared across all imports:

// store.ts
import { Elysia } from 'elysia'

class AppStore {
  private sessions = new Map<string, { userId: string; expiresAt: number }>()

  setSession(token: string, userId: string, ttlMs: number) {
    this.sessions.set(token, {
      userId,
      expiresAt: Date.now() + ttlMs
    })
  }

  getSession(token: string): string | null {
    const session = this.sessions.get(token)
    if (!session) return null
    if (Date.now() > session.expiresAt) {
      this.sessions.delete(token)
      return null
    }
    return session.userId
  }

  clearExpired() {
    const now = Date.now()
    for (const [token, session] of this.sessions) {
      if (now > session.expiresAt) {
        this.sessions.delete(token)
      }
    }
  }
}

export const appStore = new AppStore()

// Integrate with Elysia
export const storePlugin = new Elysia({ name: 'store' })
  .state('store', appStore)
  .onStart(() => {
    setInterval(() => appStore.clearExpired(), 60_000)
  })
// main.ts
import { storePlugin } from './store'

const app = new Elysia()
  .use(storePlugin)
  .post('/login', ({ body, store }) => {
    const token = crypto.randomUUID()
    store.store.setSession(token, (body as any).userId, 3600_000)
    return { token }
  })
  .get('/session/:token', ({ params, store }) => {
    const userId = store.store.getSession(params.token)
    if (!userId) return new Response('Invalid session', { status: 401 })
    return { userId }
  })
  .listen(3000)

Pattern: Integrating Redis for Distributed State

When running multiple instances of your Elysia server, in-memory state is not shared across processes. In this case, you need an external store like Redis. Here is how to integrate Redis with Elysia state:

import { Elysia } from 'elysia'
import { createClient } from 'redis'

// redis-plugin.ts
export const redisPlugin = new Elysia({ name: 'redis' })
  .state('redis', null as unknown as ReturnType<typeof createClient>)
  .onStart(async ({ store }) => {
    const client = createClient({ url: process.env.REDIS_URL ?? 'redis://localhost:6379' })
    client.on('error', (err) => console.error('Redis error:', err))
    await client.connect()
    store.redis = client
    console.log('Redis connected')
  })
  .onStop(async ({ store }) => {
    await store.redis?.quit()
    console.log('Redis disconnected')
  })
  .derive(({ store }) => ({
    cache: {
      get: (key: string) => store.redis.get(key),
      set: (key: string, value: string, ttl?: number) =>
        ttl ? store.redis.set(key, value, { EX: ttl }) : store.redis.set(key, value),
      del: (key: string) => store.redis.del(key)
    }
  }))

// main.ts
const app = new Elysia()
  .use(redisPlugin)
  .get('/data/:key', async ({ params, cache }) => {
    const cached = await cache.get(params.key)
    if (cached) return { source: 'cache', data: JSON.parse(cached) }
    const data = { timestamp: Date.now(), value: 'computed' }
    await cache.set(params.key, JSON.stringify(data), 60)
    return { source: 'compute', data }
  })
  .listen(3000)

This pattern wraps the Redis client in a plugin, exposes convenient cache methods through derive, and handles connection lifecycle through onStart and onStop hooks.

Pattern: Type-Safe State with Schema Validation

Elysia includes a powerful validation system powered by TypeBox. You can use it to validate state shape, ensuring that your state always conforms to expected schemas. This is particularly useful when state is loaded from external sources:

import { Elysia, t } from 'elysia'

const FeatureFlagsSchema = t.Object({
  newDashboard: t.Boolean(),
  betaFeatures: t.Boolean(),
  maxUploadSize: t.Number()
})

type FeatureFlags = typeof FeatureFlagsSchema.static

const defaultFlags: FeatureFlags = {
  newDashboard: true,
  betaFeatures: false,
  maxUploadSize: 10_485_760
}

const app = new Elysia()
  .state('flags', defaultFlags)
  .get('/flags', ({ store }) => store.flags)
  .patch('/flags', ({ body, store }) => {
    // Merge and validate
    const updated = { ...store.flags, ...body }
    const parsed = t.Validate(FeatureFlagsSchema, updated)
    if (!parsed.ok) {
      return new Response(JSON.stringify(parsed.errors), { status: 400 })
    }
    store.flags = updated
    return store.flags
  }, {
    body: t.Partial(FeatureFlagsSchema)
  })
  .listen(3000)

Best Practices for State Management in Elysia

To get the most out of Elysia's state management, follow these best practices:

Testing Stateful Elysia Applications

Testing stateful applications requires careful handling of state initialization. Here is a pattern for testing Elysia apps with mocked state:

import { Elysia } from 'elysia'
import { expect, test } from 'bun:test'

// app.ts
export function createApp(initialState?: Record<string, unknown>) {
  const app = new Elysia()
    .state('items', new Map<string, string>())
    .state('config', { debug: false })

  if (initialState) {
    for (const [key, value] of Object.entries(initialState)) {
      app.state(key as any, value)
    }
  }

  return app
    .get('/items/:key', ({ params, store }) => {
      return store.items.get(params.key) ?? null
    })
    .post('/items/:key', ({ params, body, store }) => {
      store.items.set(params.key, body as string)
      return { success: true }
    })
}

// app.test.ts
test('should store and retrieve items', async () => {
  const app = createApp().listen(0)
  const port = app.server?.port

  await fetch(`http://localhost:${port}/items/foo`, {
    method: 'POST',
    body: 'bar'
  })

  const res = await fetch(`http://localhost:${port}/items/foo`)
  const data = await res.text()
  expect(data).toBe('bar')

  app.server?.stop()
})

By exporting a factory function, you can create fresh Elysia instances for each test, ensuring complete isolation between test cases.

Conclusion

State management in Elysia is both powerful and ergonomic, thanks to its built-in state and derive methods combined with full type inference. By leveraging plugins, lifecycle hooks, and external libraries like Redis, you can build applications that are modular, type-safe, and scalable. The key is to understand the distinction between application-level state and request-level state, to encapsulate concerns in plugins, and to follow best practices around initialization, typing, and testing. With these patterns in your toolkit, you are well-equipped to manage state effectively in any Elysia application, from small APIs to large distributed systems.

๐Ÿ›  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