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:
- Type Safety: Elysia's state is fully typed, meaning your IDE and compiler catch mistakes before they reach production.
- Consistency: Shared state ensures every route and plugin operates on the same data shape, reducing bugs.
- Testability: By centralizing state, you can easily mock or override values during testing.
- Plugin Composition: Elysia plugins can extend or consume state, enabling clean separation of concerns.
- Performance: State is attached at app initialization, avoiding repeated expensive setup on every request.
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:
- Prefer plugins for shared state: Encapsulate state in plugins rather than defining everything on the main app instance. This improves modularity and reusability.
- Use
derivefor request-scoped data: Anything that depends on the request, such as the current user or request ID, should be derived rather than stored in global state. - Initialize async state in lifecycle hooks: Use
onStartfor async initialization like database connections, andonStopfor cleanup. - Avoid mutating global state in handlers: If you need to track metrics or counters, consider using an external store or a dedicated plugin to avoid race conditions.
- Type your state explicitly: While Elysia infers types automatically, adding explicit interfaces for complex state objects improves readability and documentation.
- Keep state minimal: Only store what you truly need. Large state objects increase memory usage and can make debugging harder.
- Use external stores for distributed systems: When running multiple instances, move shared state to Redis, Memcached, or a database to ensure consistency.
- Test with overridden state: In your test setup, create Elysia instances with mock state to isolate tests from external dependencies.
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.