Advanced
Register custom typed context, access entity collections directly, and build reusable query/mutation factories.
Custom Typed Context
By default, the only context available inside queryFn and mutationFn is the TanStack QueryClient. In real applications, you'll want to pass additional services — a database client, an API wrapper, auth tokens, etc. — so they're available in every query and mutation without importing globals.
mobx-query supports this via global namespace augmentation. You define your custom context type once, and it becomes the inferred type for the context parameter across every query and mutation in your app.
Step 1: Define Your Custom Context
Create a registration file that extends the base MQClientContext and registers it via the MobXQuery global namespace:
import type { MQClientContext as MQClientContextBase } from "@mobx-query/core";
// Your app's custom services
interface ApiClient {
get<T>(url: string): Promise<T>;
post<T>(url: string, body: unknown): Promise<T>;
patch<T>(url: string, body: unknown): Promise<T>;
delete(url: string): Promise<void>;
}
// Extend the base context with your custom properties
export interface MQClientContext extends MQClientContextBase {
api: ApiClient;
}
// Register it globally — this makes TypeScript aware of your context type
// across all mobx-query queries and mutations
declare global {
namespace MobXQuery {
interface RegisteredContext {
context: MQClientContext;
}
}
}This file must be imported early in your application (e.g. at the top of your entry point). The global declaration needs to be visible to the TypeScript compiler when it processes your query and mutation files.
Step 2: Pass Context When Initializing MQClient
When creating your MQClient, provide the custom context object. TypeScript will enforce that it matches your registered type:
import "./mq-register"; // ensure the registration is loaded
import { MQClient } from "@mobx-query/core";
import { QueryClient } from "@tanstack/react-query";
const queryClient = new QueryClient();
// Create your API client instance
const api = {
async get<T>(url: string): Promise<T> {
const res = await fetch(url);
return res.json();
},
async post<T>(url: string, body: unknown): Promise<T> {
const res = await fetch(url, {
method: "POST",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
return res.json();
},
// ... patch, delete
};
export function initMQClient() {
return new MQClient<RootStore>({
context: {
queryClient,
api, // ✅ TypeScript enforces this matches your registered context
},
entities: [Folder, Document],
rootStore: () => new RootStore(),
});
}Step 3: Use Context in Queries and Mutations
The context is now automatically typed in every queryFn and mutationFn. No casts, no generics — just destructure and use:
export class FoldersStore {
readonly foldersQuery = new QueryMany({
entity: Folder,
queryKey: () => ["folders"],
queryFn: async (_, ctx) => {
// ctx.api is fully typed as your ApiClient
return ctx.api.get<FolderData[]>("/api/folders");
},
});
readonly createFolder = new CreateMutation<CreateFolderInput, typeof Folder>({
entity: Folder,
mutationFn: async (input, entity, ctx) => {
// same typed context available in mutations
await ctx.api.post("/api/folders", { id: entity.id, ...input });
},
});
}How It Works Under the Hood
The magic is in mobx-query's types.ts:
// Base context — always includes queryClient
export interface MQClientContext {
queryClient: QueryClient;
}
// Global namespace that users can augment
declare global {
namespace MobXQuery {
interface RegisteredContext {}
}
}
// Conditional type that resolves to the user's registered context,
// or falls back to the base MQClientContext if no registration exists
export type MQClientContextRegistered = MobXQuery.RegisteredContext extends {
context: infer TContext extends MQClientContext;
}
? TContext
: MQClientContext;All query and mutation function signatures use MQClientContextRegistered as the context type. When you augment MobXQuery.RegisteredContext, the conditional type resolves to your custom interface — giving you full type safety with zero boilerplate per query.
Your custom context must extend MQClientContext (which requires
queryClient). You can add any number of additional properties — database
clients, API wrappers, feature flags, auth services, etc.
Direct Entity Collection Access
While queries are the recommended way to fetch and track data, sometimes you need to access the entire local pool of entities for a specific type — for example, to build global navigation, search across all loaded data, or debug state.
Every MQClient maintains an EntityCollection for each registered entity type. You can access it via client.getEntityCollection(EntityClass).
Lifecycle and Reactivity
Entity collections are fully observable. They update automatically whenever queries hydrate fresh data or mutations change entity state.
import { observer } from "mobx-react-lite";
import { useMQ } from "./mqclient";
import { Label } from "../label.entity";
export const LabelSidebarGroup = observer(() => {
const client = useMQ();
// Get the local collection for labels
const labels = client.getEntityCollection(Label);
// Still use a query to ensure data is fetched/cached
client.rootStore.labels.labelsQuery.useSuspenseQuery();
return (
<ul>
{labels
.filter((_, index) => index < 5)
.map((label) => (
<li key={label.id}>{label.name}</li>
))}
</ul>
);
});Collection Methods
The EntityCollection provides several utility methods that behave like their native Array counterparts but are optimized for MobX and respect the internal deletedRecordIds state (entities hidden via DeleteMutation are automatically excluded).
| Method | Description |
|---|---|
size | (Computed) The number of active entities in the collection. |
entities | (Computed) Returns all active entities as a plain array. |
clientOnlyEntities | (Computed) Returns only entities created locally (not yet confirmed by server). |
getEntityById(id) | Returns a specific entity by its identifier. |
filter(predicate) | Returns a filtered array of entities. |
find(predicate) | Returns the first entity matching the criteria. |
findIndex(pred) | Returns the index of the first matching entity. |
findLast(pred) | Returns the last entity matching the criteria. |
findLastIndex(pred) | Returns the index of the last matching entity. |
map(callback) | Returns an observable array of transformed data. |
some(predicate) | Returns true if any entity matches the criteria. |
every(predicate) | Returns true if all entities match the criteria. |
Direct collection access bypasses TanStack Query's loading/error states. Use
getEntityCollection in combination with a query hook (as shown in the
example above) to ensure the data is actually present and to handle loading
boundaries.
Entity Lifecycle and Garbage Collection
Entities in mobx-query follow a lifecycle that's tightly coupled with TanStack Query's cache management. Understanding this lifecycle helps you reason about when entities exist and when they get cleaned up.
Query Hash Tracking
Every entity keeps a Set<string> of query hashes — an internal ledger of which queries currently reference this entity. When an entity is hydrated by a query, that query's hash is added to the entity's queryHashes set.
This tracking enables two important features:
- Automatic garbage collection — when the last query referencing an entity is removed from TanStack Query's cache, the entity's
queryHashesset becomes empty, and the entity is automatically removed from theEntityCollection. 'referenced-queries'invalidation strategy — after a mutation, mobx-query knows exactly which queries to invalidate by inspecting the entity'squeryHashes.
The Cleanup Flow
TanStack Query removes a cached query (e.g. gcTime expires)
→ EntityCollection receives a "removed" event via cache subscriber
→ The removed query's hash is removed from every entity's queryHashes set
→ If an entity's queryHashes set becomes empty (no more referencing queries)
→ The entity is removed from the EntityCollectionThis means entity instances are deterministically cleaned up — you never need to manually manage entity removal. If a user navigates away from a page and the queries expire, the entities they referenced will be automatically garbage collected.
Query Fragments use gcTime: Infinity, so entities referenced only by
fragments are never garbage collected automatically. They persist as long as
their parent entity exists.
Reusable Queries and Mutations
As your application grows, you'll notice patterns repeating across entities. Every entity needs an update mutation, a delete mutation, perhaps a "by ID" query. Instead of writing the same boilerplate for each entity, you can create reusable factories that generate queries and mutations from minimal configuration.
Reusable Query Factories
Create a factory function that encapsulates common query patterns:
import { QueryMany, QueryOne, EntityConstructorAny } from "@mobx-query/core";
/**
* Creates a standard "list all" query for an entity.
*/
export function createListQuery<TEntity extends EntityConstructorAny>(
entity: TEntity,
endpoint: string,
) {
return new QueryMany({
entity,
queryKey: () => [endpoint],
queryFn: async (_, ctx) => {
return ctx.api.get(`/api/${endpoint}`);
},
});
}
/**
* Creates a standard "get by ID" query for an entity.
*/
export function createDetailQuery<TEntity extends EntityConstructorAny>(
entity: TEntity,
endpoint: string,
) {
return new QueryOne({
entity,
queryKey: () => [`${endpoint}ById`],
queryFn: async (id: string, ctx) => {
return ctx.api.get(`/api/${endpoint}/${id}`);
},
});
}Use them in your stores:
import { createListQuery, createDetailQuery } from "./query-factories";
import { Folder } from "./folder.entity";
export class FoldersStore {
readonly foldersQuery = createListQuery(Folder, "folders");
readonly folderByIdQuery = createDetailQuery(Folder, "folders");
}Reusable Mutation Factories
The same pattern works for mutations. Create factories that accept the entity constructor, instance, and endpoint:
import {
UpdateMutation,
DeleteMutation,
EntityConstructorAny,
} from "@mobx-query/core";
interface EntityMutationConfig<TEntity extends EntityConstructorAny> {
entity: TEntity;
instance: InstanceType<TEntity>;
endpoint: string;
/** Fields to include in the PATCH body (reads from the entity instance) */
fields: (keyof InstanceType<TEntity>)[];
invalidationStrategy?: OptimisticMutationInvalidationStrategy;
}
/**
* Creates a standard PATCH update mutation for an entity.
*/
export function createUpdateMutation<TEntity extends EntityConstructorAny>(
config: EntityMutationConfig<TEntity>,
) {
const { entity, instance, endpoint, fields, invalidationStrategy } = config;
return new UpdateMutation({
entity,
instance,
mutationFn: async (_, ctx) => {
const body: Record<string, unknown> = {};
for (const field of fields) {
body[field as string] = (instance as Record<string, unknown>)[
field as string
];
}
await ctx.api.patch(`/api/${endpoint}/${instance.id}`, body);
},
invalidationStrategy,
});
}
/**
* Creates a standard DELETE mutation for an entity.
*/
export function createDeleteMutation<TEntity extends EntityConstructorAny>(
entity: TEntity,
instance: InstanceType<TEntity>,
endpoint: string,
) {
return new DeleteMutation({
entity,
instance,
mutationFn: async (_, ctx) => {
await ctx.api.delete(`/api/${endpoint}/${instance.id}`);
},
});
}Use the factories on your entity:
import { Entity } from "@mobx-query/core";
import {
createUpdateMutation,
createDeleteMutation,
} from "./mutation-factories";
export class Folder extends Entity<FolderData> {
id: string = crypto.randomUUID();
@observable accessor name: string = "";
@observable accessor description: string = "";
@observable accessor isPinned: boolean = false;
readonly updateMutation = createUpdateMutation({
entity: Folder,
instance: this,
endpoint: "folders",
fields: ["name", "description", "isPinned"],
});
readonly deleteMutation = createDeleteMutation(Folder, this, "folders");
hydrate(data: FolderData) {
// ...
}
}Reusable Entity Base Classes
For the highest level of reuse, you can create abstract base entity classes that bundle common mutations and actions:
import { Entity, UpdateMutation, DeleteMutation } from "@mobx-query/core";
/**
* Base entity with built-in update and delete mutations.
* Subclasses must implement `getEndpoint()` and `getUpdateBody()`.
*/
export abstract class CrudEntity<
TData,
TId extends string | number = string,
> extends Entity<TData, TId> {
/** The REST API endpoint for this entity type (e.g. 'folders') */
protected abstract getEndpoint(): string;
/** Returns the JSON body for PATCH requests */
protected abstract getUpdateBody(): Record<string, unknown>;
readonly updateMutation = new UpdateMutation({
entity: this.constructor as any,
instance: this,
mutationFn: async (_, ctx) => {
await ctx.api.patch(
`/api/${this.getEndpoint()}/${this.id}`,
this.getUpdateBody(),
);
},
});
readonly deleteMutation = new DeleteMutation({
entity: this.constructor as any,
instance: this,
mutationFn: async (_, ctx) => {
await ctx.api.delete(`/api/${this.getEndpoint()}/${this.id}`);
},
});
}Then your entities become concise:
export class Folder extends CrudEntity<FolderData> {
id: string = crypto.randomUUID();
@observable accessor name: string = "";
@observable accessor description: string = "";
@observable accessor isPinned: boolean = false;
protected getEndpoint() {
return "folders";
}
protected getUpdateBody() {
return {
name: this.name,
description: this.description,
isPinned: this.isPinned,
};
}
hydrate(data: FolderData) {
// ...
}
}Choose the reuse strategy that fits your app's complexity. Factory functions are the most flexible and easiest to type correctly. Base classes are powerful but can become rigid — use them when your entities genuinely share the same mutation patterns. Don't over-abstract too early; start with direct definitions and extract patterns as they emerge.