Introduction to State Management in GraphQL Yoga
GraphQL Yoga is a fully-featured GraphQL server built on top of the modern graphql-js engine. While it excels at handling queries, mutations, and subscriptions, one of the most important architectural decisions you'll make when building a Yoga-powered application is how to manage state. State management in GraphQL Yoga spans two distinct dimensions: server-side state (data that lives within your GraphQL server's lifecycle) and client-side state (data consumed by frontends that communicate with Yoga). This tutorial walks through both, with practical patterns and library recommendations.
What Is State in GraphQL Yoga?
In the context of GraphQL Yoga, "state" refers to any data that persists across the execution of a GraphQL operation. This includes:
- Request-scoped state โ data attached to a single HTTP request, such as the authenticated user, database connections, or tracing spans.
- Server-scoped state โ data shared across all requests, such as in-memory caches, pub/sub channels, or connection pools.
- Execution-scoped state โ data that lives only during the resolution of a single GraphQL operation, such as DataLoader instances.
- Client-side state โ UI state, cached query results, and optimistic updates managed by the frontend consuming your Yoga API.
Understanding these layers is critical because mixing them leads to bugs like data leaks between users, stale caches, or memory bloat from unbounded server state.
Server-Side State: The Context Pattern
The primary mechanism for managing server-side state in GraphQL Yoga is the context. The context is an object constructed for every incoming request and made available to every resolver. Yoga exposes this through the context factory function passed to the server constructor.
Basic Context Setup
Here's a minimal example showing how to inject per-request state into your Yoga server:
import { createYoga } from 'graphql-yoga'
import { schema } from './schema'
const yoga = createYoga({
schema,
context: async ({ request }) => {
const token = request.headers.get('authorization')?.replace('Bearer ', '')
const user = token ? await verifyToken(token) : null
return {
user,
requestId: crypto.randomUUID(),
db: getDatabaseConnection(),
startTime: Date.now(),
}
},
})
Every resolver now receives this object as its third argument:
const resolvers = {
Query: {
me: (_, __, context) => {
if (!context.user) {
throw new Error('Not authenticated')
}
return context.user
},
},
}
Server-Scoped State with Plugins
For state that must persist across requests โ such as a pub/sub broker or a shared cache โ avoid putting it inside the context factory, since that would recreate it on every request. Instead, initialize it once at the module level or inside a Yoga plugin.
import { createYoga } from 'graphql-yoga'
import { schema } from './schema'
// Server-scoped state โ created once
const pubsub = new Map() // or use a real broker like Redis
const yoga = createYoga({
schema,
context: () => ({
pubsub, // shared reference, not recreated
}),
plugins: [
{
onPluginInit: ({ context }) => {
console.log('Yoga server initialized with shared pubsub')
},
},
],
})
Execution-Scoped State with DataLoader
One of the most common state management challenges in GraphQL is the N+1 query problem. When a resolver fetches related data per-item, you can end up with hundreds of redundant database calls. The standard solution is dataloader, which batches and caches requests within a single GraphQL operation.
The key insight is that DataLoader instances must be per-request, not shared across requests. If you reuse a DataLoader globally, you'll serve cached data from one user to another.
import DataLoader from 'dataloader'
import { createYoga } from 'graphql-yoga'
async function batchUsers(ids, db) {
const users = await db.user.findMany({ where: { id: { in: ids } } })
return ids.map(id => users.find(u => u.id === id))
}
const yoga = createYoga({
schema,
context: ({ db }) => ({
db,
loaders: {
user: new DataLoader(ids => batchUsers(ids, db)),
},
}),
})
Then in your resolver:
const resolvers = {
Post: {
author: (post, _, { loaders }) => loaders.user.load(post.authorId),
},
}
This pattern ensures that even if a single query requests 50 posts, the author lookups are batched into a single database call.
Integrating Envelop Plugins for State
GraphQL Yoga is built on top of Envelop, a plugin system for GraphQL execution. Envelop plugins are a powerful way to inject and manage state at the execution layer, before resolvers even run. This is useful for things like authentication, tracing, and feature flags.
Using the useEngine and Custom Plugins
import { createYoga } from 'graphql-yoga'
import { useExtendContext } from '@envelop/core'
const yoga = createYoga({
schema,
plugins: [
useExtendContext(async (context) => {
const traceId = context.request.headers.get('x-trace-id') || crypto.randomUUID()
return {
traceId,
logger: createLogger({ traceId }),
}
}),
],
})
The useExtendContext plugin merges additional state into the context, allowing you to compose multiple plugins without rewriting your main context factory.
Client-Side State Management Libraries
Once your Yoga server is exposing a well-structured API, the next question is how the frontend manages state. Several libraries pair exceptionally well with GraphQL Yoga.
URQL
URQL is a lightweight, highly customizable GraphQL client. It uses an exchanges architecture that makes state management explicit and composable. URQL handles caching, deduplication, and optimistic updates through configurable exchanges.
import { createClient, cacheExchange, fetchExchange } from '@urql/core'
const client = createClient({
url: 'http://localhost:4000/graphql',
exchanges: [cacheExchange, fetchExchange],
})
const query = `
query GetUsers {
users { id name }
}
`
const result = await client.query(query).toPromise()
console.log(result.data)
For React applications, the @urql/next or urql React bindings provide hooks like useQuery and useMutation that integrate seamlessly with component state.
Apollo Client
Apollo Client remains the most popular GraphQL client and works perfectly with Yoga. Its InMemoryCache provides normalized caching out of the box, which is ideal for applications with complex entity relationships.
import { ApolloClient, InMemoryCache, gql } from '@apollo/client'
const client = new ApolloClient({
uri: 'http://localhost:4000/graphql',
cache: new InMemoryCache({
typePolicies: {
User: {
keyFields: ['id'],
},
},
}),
})
const GET_USERS = gql`
query GetUsers {
users { id name email }
}
`
const { data } = await client.query({ query: GET_USERS })
Zustand for UI State
Not all client state belongs in the GraphQL cache. For purely UI-related state โ modals, theme toggles, form drafts โ a lightweight store like Zustand is often a better fit than overloading your GraphQL client.
import { create } from 'zustand'
interface UIState {
sidebarOpen: boolean
toggleSidebar: () => void
selectedUserId: string | null
setSelectedUser: (id: string | null) => void
}
export const useUIStore = create<UIState>((set) => ({
sidebarOpen: false,
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
selectedUserId: null,
setSelectedUser: (id) => set({ selectedUserId: id }),
}))
This separation keeps your GraphQL cache focused on server data while UI state remains independent and easy to reason about.
Combining Server and Client State
A common architectural pattern is to use the GraphQL cache as the source of truth for server data and a separate store for local/UI state. When the two need to interact โ for example, showing a "loading" indicator while a mutation runs โ you bridge them with mutation lifecycle hooks.
import { useMutation } from '@apollo/client'
import { useUIStore } from './uiStore'
function DeleteUserButton({ userId }) {
const setSelectedUser = useUIStore((s) => s.setSelectedUser)
const [deleteUser, { loading }] = useMutation(DELETE_USER, {
variables: { id: userId },
onCompleted: () => setSelectedUser(null),
update(cache) {
cache.evict({ id: `User:${userId}` })
cache.gc()
},
})
return (
<button disabled={loading} onClick={() => deleteUser()}>
{loading ? 'Deleting...' : 'Delete'}
</button>
)
}
Here, the Apollo cache handles the server-side entity removal, while Zustand manages the UI selection state. The onCompleted callback bridges the two layers cleanly.
Best Practices
- Keep context factories async-friendly. Authentication and database lookups are often asynchronous. Always make your context function
asyncto avoid surprises. - Never share DataLoader instances across requests. Always instantiate them inside the context factory so caching is scoped to a single operation.
- Separate server-scoped from request-scoped state. Module-level variables are fine for shared resources like connection pools, but per-user data must live in the context.
- Use normalized caching on the client for relational data. Apollo's
InMemoryCacheor URQL's@urql/exchange-graphcacheprevent redundant fetches and keep your UI consistent. - Don't put UI state in your GraphQL cache. Modal visibility, theme, and form drafts belong in a dedicated store like Zustand or Jotai.
- Leverage Envelop plugins for cross-cutting concerns. Logging, tracing, and auth are better expressed as plugins than scattered across resolvers.
- Type your context. Use TypeScript to define a
ServerContextinterface so resolvers get autocomplete and compile-time safety. - Avoid global mutable server state when possible. If you need shared mutable state, consider Redis or another external store rather than in-process memory, especially in serverless environments.
Conclusion
State management in GraphQL Yoga is fundamentally about understanding the boundaries between request-scoped, server-scoped, execution-scoped, and client-side state. By using the context factory for per-request data, DataLoader for execution-scoped batching, Envelop plugins for cross-cutting concerns, and a thoughtful combination of GraphQL caching and lightweight UI stores on the client, you can build applications that are both performant and maintainable. The key is to keep each layer focused on its responsibility: let the server manage server state, let the GraphQL cache manage server data on the client, and let a dedicated store manage pure UI state. When these boundaries are respected, your Yoga-powered application scales gracefully from a prototype to a production system.