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