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