Skip to main content
Version: Next (2.x)

Context Store

Context Store is a feature in Baeta that provides a pattern for managing request-scoped data with lazy loading and caching capabilities. It allows you to define values that can be loaded on-demand and cached throughout the request lifecycle, while maintaining full type safety.

Why Use Context Store?

Context Store solves several common problems in GraphQL APIs:

1. Better Type Safety for Authentication

Without Context Store, handling both authenticated and public routes often leads to compromised type safety:

// Without Context Store
type Context = {
user: User | null; // Have to make it optional for public routes
};

// Forces unnecessary null checks even in authenticated resolvers
const profileQuery = Query.profile.resolve(({ ctx }) => {
if (!ctx.user) {
// Required even though we know it's authenticated
throw new Error("Unauthenticated");
}
return ctx.user;
});

With Context Store, you can have precise types for each use case:

// With Context Store
const optionalUser = await getOptionalUser(ctx); // User | null - for public routes
const user = await getUser(ctx); // User - for authenticated routes, automatically throws if not authenticated

2. Lazy Loading & Performance

Context Store prevents unnecessary database queries by:

  • Loading data only when it's actually needed
  • Caching the result for the entire request lifecycle

For example:

// Without Context Store
const context = {
user: await db.user.findUnique({ where: { id: userId } }), // Always queries DB even if user data isn't needed
};

// With Context Store
const user = await getUser(ctx); // Only queries DB when this line is executed
// And subsequent calls in the same request will use cached data

3. Request-Scoped Caching

Many applications repeatedly fetch the same data within a single request:

// Without caching - hits database multiple times
const userProfile = await db.user.findUnique(...);
const userPreferences = await db.user.findUnique(...); // Same user, another query

// With Context Store - single database query
const user = await getUser(ctx); // First call fetches from DB
const userAgain = await getUser(ctx); // Uses cached data

Creating a Store

The simplest way to create a store is createContextStoreWithLoader, which bundles the loader function with the store definition:

import { createContextStoreWithLoader } from "@baeta/core";
import { UnauthenticatedError } from "@baeta/errors";
import type { User } from "./__generated__/types.ts";
import type { Context } from "./types/context.ts";

const optionalUserStoreKey = Symbol("optionalUserStore");

export const [getOptionalUser, initOptionalUserStore] =
createContextStoreWithLoader<User | null, Context>(
optionalUserStoreKey,
async (ctx) => {
if (!ctx.userId) {
return null;
}
return db.user.findUnique({ where: { id: ctx.userId } });
},
);

The returned tuple gives you:

  • getOptionalUser(ctx) — retrieves the value, triggering the loader on first access
  • initOptionalUserStore(ctx) — initializes the store on the context (call this during context creation)

Chaining Stores

Stores can build upon each other. Here a required user store chains off the optional one:

const userStoreKey = Symbol("userStore");

export const [getUser, initUserStore] =
createContextStoreWithLoader<User, Context>(
userStoreKey,
async (ctx) => {
const user = await getOptionalUser(ctx);
if (!user) {
throw new UnauthenticatedError();
}
return user;
},
);

Now getUser(ctx) returns User (never null) and automatically throws if the user isn't authenticated.

Initializing Stores

Initialize your stores during context creation:

export const yoga = createYoga<ServerContext, Context>({
schema: baeta.schema,
context: () => {
const ctx: Context = {
userId: "1",
};

initOptionalUserStore(ctx);
initUserStore(ctx);

return ctx;
},
});

Using Stores in Resolvers

Access store values in your resolvers:

const { Query } = UserModule;

const meQuery = Query.me.resolve(async ({ ctx }) => {
// Optional user store - won't throw if user isn't authenticated
const optionalUser = await getOptionalUser(ctx); // User | null

// Required user store - will throw UnauthenticatedError if user isn't authenticated
const requiredUser = await getUser(ctx); // User

return requiredUser;
});

Caching Behavior

Store values are cached for the entire request lifecycle. Multiple calls to the same store will only trigger the loader once:

const exampleQuery = Query.example.resolve(async ({ ctx }) => {
// All these calls will only trigger the loader once
await Promise.all([
getOptionalUser(ctx),
getOptionalUser(ctx),
getOptionalUser(ctx),
]);

// Uses cached value
const user = await getOptionalUser(ctx);
});

Passing Arguments to Loaders

createContextStoreWithLoader supports additional arguments beyond context. This is useful when the loader needs data that's only available at initialization time:

const orgStoreKey = Symbol("orgStore");

export const [getOrg, initOrgStore] =
createContextStoreWithLoader<Organization, Context, [string]>(
orgStoreKey,
async (ctx, orgId) => {
return db.organization.findUniqueOrThrow({ where: { id: orgId } });
},
);

// During context creation
initOrgStore(ctx, requestOrgId);

Advanced: Manual Loader Setup

For cases where you need full control over the loader, use createContextStore directly. This separates store creation from loader assignment:

import { createContextStore } from "@baeta/core";

const optionalUserStoreKey = Symbol("optionalUserStore");

export const [getOptionalUser, setOptionalUserLoader] =
createContextStore<User | null>(optionalUserStoreKey);

// During context creation, provide the loader as a closure
setOptionalUserLoader(ctx, () => {
if (!ctx.userId) {
return null;
}
return db.user.findUnique({ where: { id: ctx.userId } });
});

This is useful when the loader depends on values that can't be passed as simple arguments, or when you want to compose loaders dynamically.

Configuration Options

Both createContextStore and createContextStoreWithLoader accept these options:

type StoreOptions = {
eager?: boolean; // If true, loads immediately when initialized. Default: false (lazy)
};