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

Context Store

A context store is a typed, request-scoped slot on the context that loads its value lazily and memoizes the result for the rest of the request. It's the idiomatic way to share per-request data (the current user, the active organization, a tenant config) between resolvers without padding the Context type with optional fields or paying for queries you may not use.

Why use a context store?

Precise types for auth-sensitive resolvers

Without a store, mixing authenticated and public routes pushes you toward an optional user on the context. Every authenticated resolver then has to re-check for null:

// 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 a store you get one loader per audience and no defensive null checks:

// 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

Lazy loading

Without a store, anything you put on the context runs for every request, even when no resolver needs it. A store defers the work to the first call:

// 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

Request-scoped memoization

Repeated reads in the same request return the cached value: one DB hit per store per request, regardless of how many resolvers ask.

// 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

A store is created with createContextStore. It returns a [get, setLoader] tuple. get(ctx) reads the value (triggering the loader on first access) and setLoader(ctx, loader) registers the loader against a context object.

src/store.ts
import { createContextStore } from "@baeta/core";
import type { User } from "./__generated__/types.ts";
import type { Context } from "./types/context.ts";

export async function loadOptionalUser(ctx: Context) {
if (!ctx.userId) {
return null;
}
return db.user.findUnique({ where: { id: ctx.userId } });
}

// Symbols are used as store keys so two stores can't collide by name;
// each `Symbol(...)` call creates a fresh, unique identity.
const optionalUserStoreKey = Symbol("optionalUserStore");

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

The returned tuple:

  • getOptionalUser(ctx): retrieves the value, triggering the loader on first access.
  • setOptionalUserLoader(ctx, loader): registers the loader against the context. Called once during context creation.

Chaining Stores

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

src/store.ts
import { UnauthenticatedError } from "@baeta/errors";

export async function loadUser(ctx: Context) {
const user = await getOptionalUser(ctx);
if (!user) {
throw new UnauthenticatedError();
}
return user;
}

const userStoreKey = Symbol("userStore");

export const [getUser, setUserLoader] =
createContextStore<User>(userStoreKey);

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

Initializing Stores

Register loaders during context creation:

src/app.ts
import { loadOptionalUser, loadUser, setOptionalUserLoader, setUserLoader } from "./store.ts";

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

setOptionalUserLoader(ctx, () => loadOptionalUser(ctx));
setUserLoader(ctx, () => loadUser(ctx));

return ctx;
},
});

The loader is wrapped in a closure that captures the freshly-built ctx, so each request gets its own store.

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);
});

Shortcut: createContextStoreWithLoader

When the loader doesn't need to be assembled at initialization time, createContextStoreWithLoader bundles the loader with the store definition. It's a thin wrapper over createContextStore that swaps the setLoader(ctx, loader) step for an init(ctx, ...args) call.

src/store.ts
import { createContextStoreWithLoader } from "@baeta/core";

const orgStoreKey = Symbol("orgStore");

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

Initialize it the same way as createContextStore, passing any extra args after ctx:

src/app.ts
// During context creation
initOrgStore(ctx, requestOrgId);

Reach for createContextStoreWithLoader when the loader is a pure function of (ctx, ...args). Stick with createContextStore when the loader needs to close over state that's only available at request time (e.g. headers, auth tokens, derived helpers).

Configuration Options

Both createContextStore and createContextStoreWithLoader accept these options:

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