Skip to main content

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
Query.profile(({ 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

Use createContextStore to define a store with its loading logic:

import { createContextStore } from "@baeta/core";
import { UnauthenticatedError } from "@baeta/errors";
import type { User } from "./modules/user/typedef";
import type { Context } from "./types/context";

// Define the loader function
async function loadOptionalUser(ctx: Context) {
if (!ctx.userId) {
return null;
}

const user = await db.user.findUnique({
where: { id: ctx.userId },
});

return user;
}

// Create the store
const optionalUserStoreKey = Symbol("optionalUserStore");
export const [getOptionalUser, setOptionalUserLoader] =
createContextStore<User | null>(optionalUserStoreKey, {
lazy: true, // Load the user only when requested
});

Chaining Stores

Stores can be chained to create more complex stores:

// Build upon the optional user store
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, {
lazy: true,
});

Initializing Stores

Initialize your stores in the context creation:

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

// Set up the loaders
setOptionalUserLoader(ctx, () => loadOptionalUser(ctx));
setUserLoader(ctx, () => loadUser(ctx));

return ctx;
},
});

Using Stores in Resolvers

Access store values in your resolvers:

const { Query } = getUserModule();

Query.me(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:

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

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

Configuration Options

The createContextStore function accepts these options:

type StoreOptions = {
lazy?: boolean; // If true, only loads when first accessed
// Add any future options here
};