@servicetitan/tanstack-query-mobx
v5.1.0
Published
TanStack Query (React Query) integration for MobX stores with react-ioc dependency injection. Provides declarative data fetching, caching, synchronization, and state management with automatic query deduplication and observable state.
Downloads
6,168
Readme
@servicetitan/tanstack-query-mobx
TanStack Query integration for MobX stores with @servicetitan/react-ioc dependency injection. Provides declarative data fetching, caching, and state management with automatic query deduplication.
Installation
npm install @servicetitan/tanstack-query-mobxGetting Started
1. Provide the Query Client
import { getQueryClient } from '@servicetitan/tanstack-query-mobx/composable';
export const App = provide({
singletons: [getQueryClient()],
})(observer(() => <YourApp />));2. Create a Store
Two approaches — both work with the same query client. The composable approach is recommended for new stores.
Composable (@servicetitan/tanstack-query-mobx/composable) — Recommended
The @queryStore decorator + query()/mutation() factories. All queries are typed class properties with consistent access patterns. Favors composition over inheritance.
import { queryStore, query, mutation } from '@servicetitan/tanstack-query-mobx/composable';
@queryStore
class JobsStore extends Store {
@inject(JobsApi) private api?: JobsApi;
@observable selectedJobId = 0;
jobs = query<Job[]>(() => ({
queryKey: ['scheduling', 'jobs'],
queryFn: async () => (await this.api?.getJobs())?.data ?? [],
}));
jobDetails = query<JobDetails>(() => ({
queryKey: ['scheduling', 'job', this.selectedJobId],
queryFn: async () => (await this.api?.getJob(this.selectedJobId))?.data,
enabled: this.selectedJobId > 0,
}));
deleteJob = mutation<void, { id: number }>(() => ({
mutationFn: async (arg) => await this.api?.deleteJob(arg.id),
invalidatedQueries: [['scheduling', 'jobs']],
}));
}Runtime queries and mutations (created after initialization, e.g., in response to user actions):
@queryStore
class DetailsStore extends Store {
@inject(DetailsApi) private api?: DetailsApi;
private queries = setupRuntimeQueries();
loadDetails(id: number) {
return this.queries.add<Details>(() => ({
queryKey: ['details', id],
queryFn: () => this.api?.getDetails(id),
}), ['details', id]); // deduplicates by key
}
}Refresh on mount (invalidate stale queries when the store initializes):
@queryStore
class JobsStore extends Store {
jobs = query<Job[]>(() => ({ ... }));
techs = query<Tech[]>(() => ({ ... }));
// Ensure fresh data from external store caches when this store mounts
refresh = refreshOnMount(['business-units', 'list'], ['techs', 'list']);
}Why extends Store? Stores must extend Store from @servicetitan/react-ioc to hook into the react-ioc lifecycle — initialize() runs when the provider mounts, dispose() runs when it unmounts. The @queryStore decorator uses these hooks to auto-wire queries and clean up subscriptions.
What @queryStore handles automatically:
- Injects
QueryClientStore(no@inject(QueryClientStore)needed) - Applies
@injectable()(no separate decorator needed) - Auto-discovers
QueryApi,MutationApi,RuntimeQueries,RuntimeMutations, andRefreshOnMountDefproperties and wires their lifecycle - Chains consumer
initialize()anddispose()methods - Safe with deep inheritance — prevents double setup/dispose
Inheritance (@servicetitan/tanstack-query-mobx)
Extend QueryApiStore<T> with a primary query via queryOptions getter, plus optional secondary queries via addQuery().
import { QueryApiStore, QueryApiOptions } from '@servicetitan/tanstack-query-mobx';
@injectable()
class JobsStore extends QueryApiStore<Job[]> {
@inject(JobsApi) private api?: JobsApi;
get queryOptions(): QueryApiOptions<Job[]> {
return {
queryKey: ['scheduling', 'jobs'],
queryFn: async () => (await this.api?.getJobs())?.data ?? [],
};
}
deleteJob = this.addMutation<void, { id: number }>(() => ({
mutationFn: async (arg) => await this.api?.deleteJob(arg.id),
invalidatedQueries: [['scheduling', 'jobs']],
}));
}See QueryApiStore JSDoc for full API including addQuery(), addMutation(), and lifecycle details.
3. Use in Components
const [store] = useDependencies(JobsStore);
// Composable — access via named query
const { data, isLoading } = store.jobs;
// Inheritance — primary query proxied to store
const { data, isLoading } = store;Key Concepts
Query Keys — Arrays that uniquely identify queries. The first element is a namespace that scopes the cache (e.g., ['scheduling', 'jobs', ...]). Subsequent elements identify the resource and parameters. Changes trigger automatic refetch:
queryKey: ['scheduling', 'jobs'], // module + resource
queryKey: ['scheduling', 'jobs', jobId], // refetches when jobId changesObservable State — Each query exposes data, initialized, isLoading, isFetching, isError, error
Stale Time — Default 10 minutes. Set staleTime: 0 for always fresh, Infinity for never stale.
Data-Driven Queries — MobX reactions automatically track queryKey dependencies when using a function. Use enabled to conditionally fetch:
jobs = query<Job[]>(() => ({
queryKey: ['scheduling', 'jobs', this.filters],
queryFn: async () => (await this.api?.getJobs(this.filters))?.data ?? [],
enabled: this.hasRequiredData,
}));Note: Use a function
() => ({ ... })when options depend on observables. A plain object{ ... }is evaluated once at class field initialization and does not react to changes.
Extending and Overriding
Composable — shared factory functions
Share query definitions across stores with factory functions. Pass a deps function so MobX tracks observables reactively:
// shared/queries/jobs-queries.ts
export const jobsQuery = (
deps: () => { api?: JobsApi; teamId: number }
) => query<Job[]>(() => {
const { api, teamId } = deps();
return {
queryKey: ['scheduling', 'jobs', teamId],
queryFn: async () => (await api?.getJobs(teamId))?.data ?? [],
enabled: teamId > 0,
};
});
// Different stores reuse the same definition
@queryStore
class SchedulingStore extends Store {
@inject(JobsApi) private api?: JobsApi;
@observable teamId = 0;
jobs = jobsQuery(() => ({ api: this.api, teamId: this.teamId }));
}Composable — @computed getter with ...super
Use a @computed getter on the base class, override in subclasses:
@queryStore
class JobsStore extends Store {
@inject(JobsApi) protected api?: JobsApi;
jobs = query<Job[]>(() => this.jobsQueryOptions);
@computed
get jobsQueryOptions(): QueryApiOptions<Job[]> {
return {
queryKey: ['scheduling', 'jobs'],
queryFn: async () => (await this.api?.getJobs())?.data ?? [],
};
}
}
// Override the getter — same query instance, different options
@queryStore
class FilteredJobsStore extends JobsStore {
@observable filters = {};
get jobsQueryOptions(): QueryApiOptions<Job[]> {
return {
...super.jobsQueryOptions,
queryKey: ['scheduling', 'jobs', this.filters],
};
}
}Inheritance — queryOptions getter with ...super
@injectable()
class JobsStore extends QueryApiStore<Job[]> {
@inject(JobsApi) protected api?: JobsApi;
get queryOptions(): QueryApiOptions<Job[]> {
return {
queryKey: ['scheduling', 'jobs'],
queryFn: async () => (await this.api?.getJobs())?.data ?? [],
};
}
}
// Override — spread super, tweak what you need
@injectable()
class FilteredJobsStore extends JobsStore {
@observable filters = {};
get queryOptions(): QueryApiOptions<Job[]> {
return {
...super.queryOptions,
queryKey: ['scheduling', 'jobs', this.filters],
};
}
}API Reference
Composable (@servicetitan/tanstack-query-mobx/composable)
| Export | Usage | Purpose |
|--------|-------|---------|
| @queryStore | Class decorator | Auto-wires lifecycle, injects QueryClientStore, applies @injectable() |
| query(options) | Class fields | Create a QueryApi — plain object or () => options (use function when options depend on observables) |
| mutation(options) | Class fields | Create a MutationApi — plain object or () => options (use function when options depend on observables) |
| setupRuntimeQueries() | Class fields | Container for queries created after initialization — .add() and .get() |
| setupRuntimeMutations() | Class fields | Container for mutations created after initialization — .add() and .get() |
| refreshOnMount(...targets) | Class fields | Invalidate QueryApi instances and/or QueryKeys on store initialization |
All helpers are declarative class fields — the decorator auto-discovers and wires them. No this argument needed.
Inheritance (@servicetitan/tanstack-query-mobx)
| Export | Purpose |
|--------|---------|
| QueryApiStore<T> | Base class — extend and override queryOptions getter for the primary query |
| QueryApi<T> | Individual query manager — data, isLoading, invalidate(), refetch(), etc. |
| MutationApi<T, V> | Mutation manager — runMutation(), isPending, isSuccess, isError |
| QueryApiOptions<T> | Query configuration extending TanStack's QueryObserverOptions |
| MutationApiOptions<T, V> | Mutation configuration extending TanStack's MutationObserverOptions |
| QueryClientStore | Manages the TanStack QueryClient instance |
| getQueryClient() | Factory for providing QueryClientStore in a Provider. Pass a QueryClientConfig for custom defaults (local clients only) |
QueryApiStore provides addQuery() for secondary queries, addMutation() for mutations, and proxies primary query state (data, isLoading, initialized, etc.) to the store. See QueryApiStore JSDoc for full API.
Shared Clients (Micro-Frontends)
For micro-frontend architectures where multiple app instances share query cache:
'page'— Reference-counted, disposes when last consumer unmounts'app'— Persists until browser refresh
See getQueryClient and QueryApiOptions.globalClient JSDoc.
Testing
Use ContainerBuilder from @servicetitan/tanstack-query-mobx/test — it handles QueryClientStore setup, store initialization, and waits for all queries to settle.
import { ContainerBuilder } from '@servicetitan/tanstack-query-mobx/test';
const { container, initialize } = new ContainerBuilder()
.add(JobsStore)
.add(JobsApi)
.build();
jest.spyOn(container.get(JobsApi), 'getJobs').mockResolvedValue({
data: [{ id: 1, title: 'Plumbing' }],
});
await initialize();
expect(container.get(JobsStore).jobs.data).toHaveLength(1);See the full Testing Guide for ContainerBuilder API, skipping queries, mock values, manual setup, and query state reference.
Source Code
All classes and functions have comprehensive TSDoc comments. Explore the source for detailed documentation:
- query-store-decorator/ —
@queryStoredecorator,query(),mutation(),refreshOnMount(),setupRuntimeQueries(),setupRuntimeMutations() - query-api.store.ts —
QueryApiStorebase class - query.api.ts —
QueryApi,QueryApiOptions - mutation.api.ts —
MutationApi,MutationApiOptions - query-client.store.ts —
QueryClientStore - get-query-client.ts —
getQueryClient()factory
License
Part of the @servicetitan/anvil-uikit-contrib monorepo.
