โ† Back to DevBytes

State Management in GraphQL Yoga: Patterns and Libraries

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:

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

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.

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