@servicetitan/tanstack-query-mobx
v8.2.1
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.
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 changesQuery State — Each QueryApi instance (and QueryApiStore) exposes data, error, and observable flags that mirror TanStack Query's useQuery:
| Flag | When it's true |
|------|-------------------|
| initialized | After the first query attempt settles (package-specific, not in TanStack) |
| isPending | No settled result yet — includes disabled queries that have never run |
| isLoading | First fetch in flight (isFetching && isPending) — use for initial loading UI |
| isFetching | Any fetch in flight, including background refetches |
| isRefetching | Background refetch after the query already has data |
| isSuccess | Query succeeded |
| isError | Query failed |
Which flag to use:
- Initial loading spinner:
!initialized— false until the first query attempt settles - Background refresh indicator:
isRefetching - Any network activity:
isFetching - Error state:
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],
};
}
}Cross-mount Shared State
Use the query cache as shared state (selection, filters) across mounts that share the same QueryClientStore. The query never fetches; writes go through updateQueryData:
@queryStore
class SelectionStore extends Store {
selection = query<Item | null>({
queryKey: ['scheduling', 'selection'],
queryFn: () => null,
enabled: false,
gcTime: Infinity,
staleTime: Infinity,
initialData: null,
});
@action setSelection = (item: Item | null) => {
this.selection.updateQueryData(item ? structuredClone(item) : null);
};
}Writers go through the store's public API; avoid queryClient.setQueryData from callers.
Common Pitfalls
@injectable()with@queryStore— the decorator already applies it@queryStoreon a class that extendsQueryApiStore— pick one approach- Composable
store.isLoading— use the named query:store.jobs.isLoading - Plain-object options that read observables — use
query(() => ({ ... })) - Returning
[]fromqueryFnwhen inputs aren't ready — useenabled - Mutating observables after
awaitwithoutrunInAction - Hand-rolled loading flags for writes — use
mutation()/addMutation() - Bursty live-event invalidations — use
.invalidate({ dedupe: true })
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. See the Testing Guide.
Agent Skills
AI agent skills that point agents at this README and the testing guide live in .claude/skills/.
Install into a repo (commit the result so your team gets them):
npx skills add servicetitan/anvil-uikit-contrib --skill tanstack-query-mobx-store
npx skills add servicetitan/anvil-uikit-contrib --skill tanstack-query-mobx-testOr install for yourself across all repos by adding -g. Update later with npx skills update (add -g for global installs).
Claude Code: add the repo marketplace from .claude-plugin/marketplace.json (/plugin marketplace add servicetitan/anvil-uikit-contrib), then install the tanstack-query-mobx plugin.
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.
