@servicetitan/docs-anvil-uikit-contrib 38.7.0 → 39.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,187 @@
1
+ ---
2
+ title: Queries
3
+ ---
4
+
5
+ # Queries
6
+
7
+ Queries are **data-driven** — they run automatically based on their options, not by explicit calls. When a store initializes, its queries subscribe to the TanStack client and begin fetching. Changes to `queryKey` values trigger refetches via MobX reactivity.
8
+
9
+ ## Creating Queries
10
+
11
+ ### Composable (recommended)
12
+
13
+ Use the `query()` factory as a class field with a function `() => ({...})`. MobX tracks observable dependencies accessed inside the function and triggers refetch when they change.
14
+
15
+ ```tsx
16
+ @queryStore
17
+ class JobsStore extends Store {
18
+ @inject(JobsApi) private api?: JobsApi;
19
+ @observable selectedJobId = 0;
20
+
21
+ jobs = query<Job[]>(() => ({
22
+ queryKey: ['scheduling', 'jobs', this.selectedJobId],
23
+ queryFn: async () => (await this.api?.getJobs(this.selectedJobId))?.data ?? [],
24
+ enabled: this.selectedJobId > 0,
25
+ }));
26
+
27
+ techs = query<Tech[]>(() => ({
28
+ queryKey: ['scheduling', 'techs'],
29
+ queryFn: async () => (await this.api?.getTechs())?.data ?? [],
30
+ }));
31
+ }
32
+ ```
33
+
34
+ Multiple queries are just additional class fields — no special API needed.
35
+
36
+ > **Note:** `query()` also accepts a plain object `query({...})` instead of a function, but this is evaluated once at class field initialization and won't react to observable changes. Use the function form unless you have a reason not to.
37
+
38
+ ### Inheritance
39
+
40
+ Use the `queryOptions` getter for the primary query, and `addQuery()` for additional queries.
41
+
42
+ ```tsx
43
+ @injectable()
44
+ class JobsStore extends QueryApiStore<Job[]> {
45
+ @inject(JobsApi) private api?: JobsApi;
46
+
47
+ get queryOptions(): QueryApiOptions<Job[]> {
48
+ return {
49
+ queryKey: ['scheduling', 'jobs'],
50
+ queryFn: async () => (await this.api?.getJobs())?.data ?? [],
51
+ };
52
+ }
53
+
54
+ techs = this.addQuery<Tech[]>(() => ({
55
+ queryKey: ['scheduling', 'techs'],
56
+ queryFn: async () => (await this.api?.getTechs())?.data ?? [],
57
+ }));
58
+ }
59
+ ```
60
+
61
+ ## Query Options
62
+
63
+ ### queryKey (required)
64
+
65
+ Array of serializable values that uniquely identifies the query.
66
+
67
+ - Use at least two entries — the first as a **namespace** string (e.g., `'scheduling'`, `'myapp'`) to scope and organize queries
68
+ - Changes to any value in the key trigger a refetch (data-driven query)
69
+ - Multiple stores with the same key share one cache and one network request (deduplication)
70
+ - If data is already cached for a key, new subscribers get the cached data immediately (respecting `staleTime`)
71
+
72
+ ```tsx
73
+ queryKey: ['scheduling', 'jobs'], // namespace + resource
74
+ queryKey: ['scheduling', 'jobs', jobId], // refetches when jobId changes
75
+ queryKey: ['scheduling', 'jobs', [...ids].sort()], // sort arrays so [1,2] and [2,1] hit the same cache
76
+ ```
77
+
78
+ ### queryFn (required)
79
+
80
+ Async function that fetches and returns data. Can use any mechanism — Axios, fetch, generated API functions, or just return a value.
81
+
82
+ ```tsx
83
+ queryFn: async () => (await this.api?.getJobs())?.data ?? [],
84
+ ```
85
+
86
+ If the function depends on data like an ID, include that data in `queryKey` so the function re-runs when it changes.
87
+
88
+ ### enabled
89
+
90
+ Boolean, defaults to `true`. The query only runs when `enabled` is `true`. Toggling from `false` to `true` triggers a fetch.
91
+
92
+ ```tsx
93
+ enabled: this.selectedJobId > 0, // wait until an ID is selected
94
+ ```
95
+
96
+ ### staleTime
97
+
98
+ Milliseconds before cached data is considered stale. Default: **10 minutes** (set by `QueryClientStore`).
99
+
100
+ - **`Infinity`** — data never goes stale. Only one fetch per key, no matter how many stores subscribe. Refetch only via manual `refetch()` or `invalidate()`.
101
+ - **`0`** — data is always stale. Every new subscriber or key change triggers a refetch.
102
+ - **`10 * 60 * 1000` (default)** — data is fresh for 10 minutes. New subscribers within that window get cached data without refetching.
103
+
104
+ Stale queries re-fetch on **fetch events**: a new store subscribing with the same key, or a `queryKey` value changing. Re-fetches are deduplicated — only one network request per key at a time.
105
+
106
+ ### gcTime (garbage collection)
107
+
108
+ Milliseconds before an **inactive** query is garbage collected. Default: **5 minutes**. A query becomes inactive when nothing is subscribed to it — either because the store unmounted, or because a reactive `queryKey` change shifted the observer to a different key.
109
+
110
+ ### retry
111
+
112
+ Number of retry attempts for failed queries. Default: **3** with exponential backoff.
113
+
114
+ ### refetchInterval
115
+
116
+ Number or function. When set, refetches at the specified interval. The interval resets after each refresh.
117
+
118
+ See all options at [TanStack Query useQuery reference](https://tanstack.com/query/v5/docs/framework/react/reference/useQuery).
119
+
120
+ ## Integration-Specific Options
121
+
122
+ These are additions to TanStack's standard options:
123
+
124
+ | Option | Default | Description |
125
+ |--------|---------|-------------|
126
+ | `disposeQuery` | `undefined` | Remove the query from the client cache on dispose. `true` = exact key, number = first N key segments |
127
+ | `autoInitialize` | `true` | Set `initialized` to `true` after first successful fetch. Set to `false` to control it manually |
128
+ | `onSuccess` | `undefined` | Callback with query data after success. Also fires on `updateQueryData()` |
129
+ | `onError` | `undefined` | Callback with error object after failure (after retries) |
130
+ | `globalClient` | `undefined` | Use a shared page or app-level query client. See [Shared Clients](./04-shared-clients) |
131
+
132
+ ## Query State
133
+
134
+ Each query exposes observable state:
135
+
136
+ | Flag | When `true` |
137
+ |------|-------------|
138
+ | `initialized` | After the first query attempt settles (package-specific, not in TanStack) |
139
+ | `isPending` | No settled result yet — includes disabled queries that have never run |
140
+ | `isLoading` | First fetch in flight (`isFetching && isPending`) — use for initial loading UI |
141
+ | `isFetching` | Any fetch in flight, including background refetches |
142
+ | `isRefetching` | Background refetch after data already exists |
143
+ | `isSuccess` | Query succeeded |
144
+ | `isError` | Query failed (after retries) |
145
+
146
+ **Which to use:**
147
+ - **Initial loading spinner**: `!initialized` — false until the first query attempt settles
148
+ - **Background refresh indicator**: `isRefetching`
149
+ - **Error handling**: `isError` + `error`
150
+
151
+ ## Query Methods
152
+
153
+ | Method | Description |
154
+ |--------|-------------|
155
+ | `invalidate()` | Mark query as stale and refetch. Supports `dedupe` option to skip if already invalidated |
156
+ | `refetch()` | Manually re-run the query |
157
+ | `updateQueryData(data)` | Update cache without fetching. Triggers `onSuccess` |
158
+ | `cancel()` | Cancel an in-flight request |
159
+ | `getQueryState()` | Get the full TanStack query state object |
160
+
161
+ ## Runtime Queries
162
+
163
+ For queries created after initialization (e.g., in response to user actions), use `setupRuntimeQueries()`:
164
+
165
+ ```tsx
166
+ import { setupRuntimeQueries } from '@servicetitan/tanstack-query-mobx/composable';
167
+
168
+ @queryStore
169
+ class DetailsStore extends Store {
170
+ private queries = setupRuntimeQueries();
171
+
172
+ loadDetails(id: number) {
173
+ // Second arg is the key — calling add() again with the same key returns the existing query
174
+ return this.queries.add<Details>(() => ({
175
+ queryKey: ['details', id],
176
+ queryFn: () => this.api?.getDetails(id),
177
+ }), ['details', id]);
178
+ }
179
+
180
+ getDetails(id: number) {
181
+ // Retrieve a previously created query by key
182
+ return this.queries.get<Details>(['details', id]);
183
+ }
184
+ }
185
+ ```
186
+
187
+ Queries created via `.add()` are automatically set up with the store's query client and disposed when the store disposes. The key serves two purposes: `.add()` returns the existing query if one was already created with that key, and `.get()` retrieves it later.
@@ -0,0 +1,141 @@
1
+ ---
2
+ title: Mutations
3
+ ---
4
+
5
+ # Mutations
6
+
7
+ Mutations are for updating, creating, or deleting data. Unlike queries, mutations are **called manually** via `runMutation()`. They can automatically invalidate (refetch) related queries after success.
8
+
9
+ ## Creating Mutations
10
+
11
+ ### Composable (recommended)
12
+
13
+ Use the `mutation()` factory as a class field:
14
+
15
+ ```tsx
16
+ @queryStore
17
+ class TasksStore extends Store {
18
+ @inject(TasksApi) private api?: TasksApi;
19
+
20
+ snooze = mutation<void, { id: number; note?: string }>(() => ({
21
+ mutationFn: async (arg) => (await this.api?.snooze(arg.id, arg.note))?.data,
22
+ invalidatedQueries: [['myapp', 'tasks'], ['myapp', 'progress-tracker']],
23
+ onSuccess: () => {
24
+ toast.success({ title: 'Task Snoozed', message: 'We will check back soon.' });
25
+ },
26
+ onError: error => {
27
+ toast.danger({ title: 'Task Snooze', message: error.message });
28
+ },
29
+ }));
30
+
31
+ handleSnoozed = async (id: number, note?: string) => {
32
+ await this.snooze.runMutation({ id, note });
33
+ };
34
+ }
35
+ ```
36
+
37
+ ### Inheritance
38
+
39
+ Use `addMutation()` on the store:
40
+
41
+ ```tsx
42
+ @injectable()
43
+ class TasksStore extends QueryApiStore<Task[]> {
44
+ @inject(TasksApi) private api?: TasksApi;
45
+
46
+ snooze = this.addMutation<void, { id: number; note?: string }>(() => ({
47
+ mutationFn: async (arg) => (await this.api?.snooze(arg.id, arg.note))?.data,
48
+ invalidatedQueries: [['myapp', 'tasks']],
49
+ }));
50
+ }
51
+ ```
52
+
53
+ ## Mutation Options
54
+
55
+ ### mutationKey
56
+
57
+ Array of serializable data. Optional — not needed for most use cases.
58
+
59
+ ### mutationFn (required)
60
+
61
+ Async function called when `runMutation()` is invoked. The argument is whatever you pass to `runMutation()` — an object, a single value, or nothing (`void`).
62
+
63
+ ```tsx
64
+ mutationFn: async (arg) => (await this.api?.deleteJob(arg.id))?.data,
65
+ ```
66
+
67
+ ### invalidatedQueries
68
+
69
+ Array of query keys to invalidate (refetch) after a successful mutation. Using the first key in the array as a namespace will invalidate all queries starting with that key.
70
+
71
+ ```tsx
72
+ invalidatedQueries: [
73
+ ['myapp', 'tasks'], // invalidates queries with these first two keys
74
+ ['myapp', 'progress-tracker'],
75
+ ['myapp'], // invalidates ALL queries under 'myapp'
76
+ ],
77
+ ```
78
+
79
+ ### onSuccess / onError
80
+
81
+ Callbacks after the mutation completes. `onSuccess` receives the return value of `mutationFn`. `onError` receives the error object. These can be set both in the mutation options and in `runMutation()` — both will run.
82
+
83
+ ### retry
84
+
85
+ Number of retry attempts. Default: **0** (no retries for mutations, unlike queries).
86
+
87
+ See all options at [TanStack Query useMutation reference](https://tanstack.com/query/v5/docs/framework/react/reference/useMutation).
88
+
89
+ ## Integration-Specific Options
90
+
91
+ | Option | Description |
92
+ |--------|-------------|
93
+ | `invalidatedQueries` | Query keys to refetch after successful mutation |
94
+ | `globalClient` | Use a shared page or app-level client (see [Shared Clients](./04-shared-clients)) |
95
+
96
+ ## Mutation State
97
+
98
+ Each mutation exposes observable state:
99
+
100
+ | Flag | When `true` |
101
+ |------|-------------|
102
+ | `isPending` | Mutation is in flight |
103
+ | `isSuccess` | Mutation completed successfully |
104
+ | `isError` | Mutation failed (after retries) |
105
+
106
+ ## Calling Mutations
107
+
108
+ Use `runMutation()` to execute. The argument matches the `mutationFn` parameter type:
109
+
110
+ ```tsx
111
+ // Object argument
112
+ await store.snooze.runMutation({ id: 42, note: 'Check back later' });
113
+
114
+ // With inline callbacks
115
+ await store.deleteJob.runMutation(
116
+ { id: 123 },
117
+ {
118
+ onSuccess: () => console.log('Deleted'),
119
+ onError: (error) => console.error('Failed', error),
120
+ }
121
+ );
122
+ ```
123
+
124
+ ## Runtime Mutations
125
+
126
+ For mutations created after initialization, use `setupRuntimeMutations()`:
127
+
128
+ ```tsx
129
+ import { setupRuntimeMutations } from '@servicetitan/tanstack-query-mobx/composable';
130
+
131
+ @queryStore
132
+ class ActionsStore extends Store {
133
+ private mutations = setupRuntimeMutations();
134
+
135
+ setupDelete(entityId: number) {
136
+ return this.mutations.add<void, { id: number }>(() => ({
137
+ mutationFn: (args) => this.api?.delete(entityId, args),
138
+ }), ['delete', entityId]);
139
+ }
140
+ }
141
+ ```
@@ -0,0 +1,140 @@
1
+ ---
2
+ title: Extending Stores
3
+ ---
4
+
5
+ # Extending Stores
6
+
7
+ ## Composable — Shared Factory Functions
8
+
9
+ Share query definitions across stores with factory functions. Pass a function that returns the observables and services the query needs — MobX tracks them reactively:
10
+
11
+ ```tsx
12
+ // shared/queries/jobs-queries.ts
13
+ export const jobsQuery = (
14
+ deps: () => { api?: JobsApi; teamId: number } & Partial<QueryApiOptions<Job[]>>
15
+ ) => query<Job[]>(() => {
16
+ const { api, teamId, ...overrides } = deps();
17
+ return {
18
+ queryKey: ['scheduling', 'jobs', teamId],
19
+ queryFn: async () => (await api?.getJobs(teamId))?.data ?? [],
20
+ enabled: teamId > 0,
21
+ ...overrides,
22
+ };
23
+ });
24
+
25
+ // Different stores reuse the same definition
26
+ @queryStore
27
+ class SchedulingStore extends Store {
28
+ @inject(JobsApi) private api?: JobsApi;
29
+ @observable teamId = 0;
30
+
31
+ jobs = jobsQuery(() => ({ api: this.api, teamId: this.teamId }));
32
+ }
33
+
34
+ // Consumer with reactive overrides — MobX tracks everything inside the function
35
+ @queryStore
36
+ class DashboardStore extends Store {
37
+ @inject(JobsApi) private api?: JobsApi;
38
+ @observable teamId = 1;
39
+ @observable hasPermission = false;
40
+
41
+ jobs = jobsQuery(() => ({
42
+ api: this.api,
43
+ teamId: this.teamId,
44
+ enabled: this.teamId > 0 && this.hasPermission,
45
+ }));
46
+ }
47
+ ```
48
+
49
+ ## Composable — Getter Override Pattern
50
+
51
+ Use a `@computed` getter on the base class, override in subclasses:
52
+
53
+ ```tsx
54
+ @queryStore
55
+ class JobsStore extends Store {
56
+ @inject(JobsApi) protected api?: JobsApi;
57
+
58
+ constructor() {
59
+ super();
60
+ makeObservable(this);
61
+ }
62
+
63
+ jobs = query<Job[]>(() => this.jobsOptions);
64
+
65
+ @computed
66
+ get jobsOptions(): QueryApiOptions<Job[]> {
67
+ return {
68
+ queryKey: ['scheduling', 'jobs'],
69
+ queryFn: async () => (await this.api?.getJobs())?.data ?? [],
70
+ };
71
+ }
72
+ }
73
+
74
+ // Override the getter — @override replaces @computed in subclass
75
+ @queryStore
76
+ class FilteredJobsStore extends JobsStore {
77
+ @observable filters = {};
78
+
79
+ @override
80
+ get jobsOptions(): QueryApiOptions<Job[]> {
81
+ return {
82
+ ...super.jobsOptions,
83
+ queryKey: ['scheduling', 'jobs', this.filters],
84
+ };
85
+ }
86
+ }
87
+ ```
88
+
89
+ ## Inheritance — Extending QueryApiStore
90
+
91
+ Create a base store with default options, then extend and override:
92
+
93
+ ```tsx
94
+ @injectable()
95
+ class JobsStore extends QueryApiStore<Job[]> {
96
+ @inject(JobsApi) protected api?: JobsApi;
97
+
98
+ get queryOptions(): QueryApiOptions<Job[]> {
99
+ return {
100
+ queryKey: ['scheduling', 'jobs'],
101
+ queryFn: async () => (await this.api?.getJobs())?.data ?? [],
102
+ };
103
+ }
104
+ }
105
+
106
+ // Override — spread super, tweak what you need
107
+ @injectable()
108
+ class FilteredJobsStore extends JobsStore {
109
+ @observable teamId = 0;
110
+
111
+ get queryOptions(): QueryApiOptions<Job[]> {
112
+ return {
113
+ ...super.queryOptions,
114
+ queryKey: ['scheduling', 'jobs', this.teamId],
115
+ enabled: this.teamId > 0,
116
+ };
117
+ }
118
+ }
119
+ ```
120
+
121
+ ## Refresh on Mount
122
+
123
+ When navigating between components, you may want to ensure that data from **other** stores is fresh when a store mounts.
124
+
125
+ Use the `refreshOnMount()` factory as a class field. The `@queryStore` decorator executes it automatically during `initialize()`:
126
+
127
+ ```tsx
128
+ import { refreshOnMount } from '@servicetitan/tanstack-query-mobx/composable';
129
+
130
+ @queryStore
131
+ class JobsStore extends Store {
132
+ jobs = query<Job[]>(() => ({ ... }));
133
+
134
+ // Ensure fresh data from external store caches when this store mounts
135
+ refresh = refreshOnMount(['business-units', 'list'], ['techs', 'list']);
136
+ }
137
+ ```
138
+
139
+ Accepts any number of `QueryKey` arrays and `QueryApi` instances, comma-separated.
140
+
@@ -0,0 +1,63 @@
1
+ ---
2
+ title: Shared Clients
3
+ ---
4
+
5
+ # Shared Query Clients
6
+
7
+ To sync and share cached data across multiple Micro Frontends (MFEs), a shared query client can be used at the **page** or **app** level.
8
+
9
+ ## Page-Level Client
10
+
11
+ Reference-counted — disposes when the last consumer unmounts. Two ways to set up:
12
+
13
+ ### Top-level provider (preferred)
14
+
15
+ Propagates to all stores underneath:
16
+
17
+ ```tsx
18
+ import { getQueryClient } from '@servicetitan/tanstack-query-mobx/composable';
19
+
20
+ export const App: FC = provide({
21
+ singletons: [getQueryClient('page')],
22
+ })(observer(() => <YourApp />));
23
+ ```
24
+
25
+ Multiple MFEs on the same page share a single page client. The library tracks how many consumers are using it across all MFEs — when the last one unmounts, the page client is cleaned up automatically.
26
+
27
+ ### Per-query option
28
+
29
+ Set `globalClient: 'page'` on an individual query:
30
+
31
+ ```tsx
32
+ // Composable
33
+ jobs = query<Job[]>(() => ({
34
+ queryKey: ['scheduling', 'jobs'],
35
+ queryFn: () => this.api?.getJobs(),
36
+ globalClient: 'page',
37
+ }));
38
+
39
+ // Inheritance
40
+ get queryOptions(): QueryApiOptions<Job[]> {
41
+ return {
42
+ queryKey: ['scheduling', 'jobs'],
43
+ queryFn: () => this.api?.getJobs(),
44
+ globalClient: 'page',
45
+ };
46
+ }
47
+ ```
48
+
49
+ ## App-Level Client
50
+
51
+ Persists until the browser is refreshed or closed. Use cautiously — cached data stays active across all navigation.
52
+
53
+ Set `globalClient: 'app'` on an individual query:
54
+
55
+ ```tsx
56
+ jobs = query<Job[]>(() => ({
57
+ queryKey: ['scheduling', 'jobs'],
58
+ queryFn: () => this.api?.getJobs(),
59
+ globalClient: 'app',
60
+ }));
61
+ ```
62
+
63
+ Unlike the page client, there is no `getQueryClient('app')` provider. App-level sharing is set per-query using the `globalClient` option.
@@ -0,0 +1,156 @@
1
+ ---
2
+ title: QueryClientStore
3
+ ---
4
+
5
+ # QueryClientStore
6
+
7
+ The `QueryClientStore` manages the underlying TanStack `QueryClient` instance. The `@queryStore` decorator automatically injects it and wires it into every `QueryApi` and `MutationApi` instance, so most stores don't need to reference it directly.
8
+
9
+ Use it when you need to manipulate the query cache outside of a specific query — invalidating keys, fetching data imperatively, or cancelling requests.
10
+
11
+ ## Setup
12
+
13
+ Register it in your provider via `getQueryClient()`:
14
+
15
+ ```tsx
16
+ import { getQueryClient } from '@servicetitan/tanstack-query-mobx/composable';
17
+
18
+ export const App: FC = provide({
19
+ singletons: [getQueryClient()],
20
+ })(observer(() => <YourApp />));
21
+ ```
22
+
23
+ To override the defaults, pass a `QueryClientConfig` object:
24
+
25
+ ```tsx
26
+ import { getQueryClient } from '@servicetitan/tanstack-query-mobx/composable';
27
+
28
+ export const App: FC = provide({
29
+ singletons: [
30
+ getQueryClient({
31
+ defaultOptions: {
32
+ queries: { staleTime: 5 * 60 * 1000, retry: 1 },
33
+ },
34
+ }),
35
+ ],
36
+ })(observer(() => <YourApp />));
37
+ ```
38
+
39
+ Custom config is only supported for local (component-scoped) clients. Shared clients (`'page'`, `'app'`) use consistent defaults across all MFEs — see [Shared Clients](./04-shared-clients).
40
+
41
+ ## Defaults
42
+
43
+ The `QueryClientStore` creates a `QueryClient` with these defaults:
44
+
45
+ | Option | Default | Description |
46
+ |--------|---------|-------------|
47
+ | `staleTime` | 10 minutes | How long data is considered fresh before refetch |
48
+ | `refetchOnWindowFocus` | `false` | No automatic refetch when the browser tab regains focus |
49
+ | `refetchOnReconnect` | `false` | No automatic refetch when the network reconnects |
50
+ | `retry` | `3` | Number of retry attempts with exponential backoff |
51
+ | `gcTime` | 5 minutes (TanStack default) | Time before inactive queries are garbage collected |
52
+
53
+ ## API
54
+
55
+ All key-based methods (`invalidate`, `cancel`, `remove`) use **prefix matching** — `['scheduling']` matches all queries starting with that key, like `['scheduling', 'jobs']` and `['scheduling', 'stats']`. This is TanStack Query's default behavior.
56
+
57
+ ### invalidate(keys, options?)
58
+
59
+ Invalidates queries across **all** available clients (local, page, and app). Invalidated queries are marked stale and refetch automatically when an observer is subscribed.
60
+
61
+ ```tsx
62
+ // Single key
63
+ queryClientStore.invalidate(['scheduling', 'jobs']);
64
+
65
+ // Multiple keys
66
+ queryClientStore.invalidate([['scheduling', 'jobs'], ['scheduling', 'stats']]);
67
+
68
+ // With deduplication — skip if currently in an invalidated state
69
+ queryClientStore.invalidate(['scheduling', 'jobs'], { dedupe: true });
70
+
71
+ // With a different invalidation key (invalidate a broader prefix)
72
+ queryClientStore.invalidate(['scheduling', 'jobs', 42], {
73
+ invalidationKey: ['scheduling', 'jobs'],
74
+ });
75
+ ```
76
+
77
+ **Options:**
78
+
79
+ | Option | Type | Description |
80
+ |--------|------|-------------|
81
+ | `dedupe` | `boolean` | Skip if the query is currently in an invalidated state (hasn't refetched yet) |
82
+ | `invalidationKey` | `QueryKey` | Use a different key for invalidation than the one provided |
83
+
84
+ ### fetchQuery(queryConfig, options?)
85
+
86
+ Fetches a query imperatively without subscribing to it. Respects caching and `staleTime` — if fresh data is already cached, it returns that instead of making a network request. Returns a deep clone of the data.
87
+
88
+ The query will retry according to the client's retry settings (default: 3 attempts with exponential backoff) before throwing. **Wrap calls in try/catch** — errors are thrown, not swallowed.
89
+
90
+ ```tsx
91
+ try {
92
+ const report = await queryClientStore.fetchQuery({
93
+ queryKey: ['reporting', 'report', reportId],
94
+ queryFn: async () => (await api.getReport(reportId)).data,
95
+ });
96
+ } catch (error) {
97
+ // Called after all retries are exhausted
98
+ console.error('Failed to fetch report:', error);
99
+ }
100
+ ```
101
+
102
+ **Useful query config options (first parameter):**
103
+
104
+ | Option | Type | Description |
105
+ |--------|------|-------------|
106
+ | `staleTime` | `number` | Override stale time for this fetch. Set to `0` to force a fresh network request even if cached data exists |
107
+ | `retry` | `number \| boolean` | Override retry count for this specific fetch |
108
+ | `gcTime` | `number` | Override garbage collection time for this query |
109
+
110
+ **Second parameter options:**
111
+
112
+ | Option | Type | Description |
113
+ |--------|------|-------------|
114
+ | `appClient` | `boolean` | Fetch from the app-level shared client instead of the current `QueryClientStore` instance |
115
+
116
+ ### cancel(keys, options?)
117
+
118
+ Cancels in-flight requests for queries matching the provided key(s).
119
+
120
+ ```tsx
121
+ queryClientStore.cancel(['scheduling', 'jobs']);
122
+ ```
123
+
124
+ **Second parameter options:**
125
+
126
+ | Option | Type | Description |
127
+ |--------|------|-------------|
128
+ | `appClient` | `boolean` | Cancel on the app-level shared client instead of the current `QueryClientStore` instance |
129
+
130
+ ### remove(keys)
131
+
132
+ Removes queries from the cache entirely — the query, its data, and all metadata are deleted. Unlike `invalidate`, this does **not** trigger a refetch.
133
+
134
+ ```tsx
135
+ queryClientStore.remove(['old', 'data']);
136
+ ```
137
+
138
+ ### queryClient
139
+
140
+ This offers direct access to the underlying TanStack `QueryClient` instance. Use it for advanced TanStack Query options not exposed by the methods above — for example, exact key matching:
141
+
142
+ ```tsx
143
+ // Invalidate only ['scheduling', 'jobs', 42], not ['scheduling', 'jobs', 43]
144
+ queryClientStore.queryClient.invalidateQueries({
145
+ queryKey: ['scheduling', 'jobs', 42],
146
+ exact: true,
147
+ });
148
+ ```
149
+
150
+ ## When to Use Directly
151
+
152
+ Most stores don't need to inject `QueryClientStore` — each query instance already has its own `invalidate()` method (e.g., `this.jobs.invalidate()`), and the `@queryStore` decorator handles setup and disposal automatically. Inject `QueryClientStore` directly when you need to:
153
+
154
+ - **Invalidate from outside a query** — e.g., a mutation in one store needs to invalidate a query owned by a different store, using a shared key prefix
155
+ - **Fetch data imperatively** — one-off fetch without setting up a full query subscription
156
+ - **Cancel or remove queries** — clean up specific cache entries on demand
@@ -0,0 +1,75 @@
1
+ ---
2
+ title: Testing
3
+ ---
4
+
5
+ # Testing
6
+
7
+ Use `ContainerBuilder` from `@servicetitan/tanstack-query-mobx/test` to set up stores in tests. It handles `QueryClientStore` setup, store initialization, and waits for all queries to settle.
8
+
9
+ ## Quick Example
10
+
11
+ ```tsx
12
+ import { ContainerBuilder } from '@servicetitan/tanstack-query-mobx/test';
13
+
14
+ const { container, initialize } = new ContainerBuilder()
15
+ .add(JobsStore)
16
+ .add(JobsApi)
17
+ .build();
18
+
19
+ jest.spyOn(container.get(JobsApi), 'getJobs').mockResolvedValue({
20
+ data: [{ id: 1, title: 'Plumbing' }],
21
+ });
22
+
23
+ await initialize();
24
+ expect(container.get(JobsStore).jobs.data).toHaveLength(1);
25
+ ```
26
+
27
+ ## Test Defaults
28
+
29
+ `ContainerBuilder` creates a `QueryClientStore` with test-friendly defaults:
30
+ - **`staleTime: Infinity`** — queries never go stale, refetches only on explicit invalidation
31
+ - **`retry: false`** — failed queries don't retry, test failures surface immediately
32
+
33
+ ## Key Features
34
+
35
+ - **Disabled query detection** — `initialize()` won't hang on queries with `enabled: false`
36
+ - **`skipQueries` option** — skip waiting for specific queries: `await initialize({ skipQueries: [store.details] })`
37
+ - **`ignoreStores` option** — skip entire stores: `await initialize({ ignoreStores: [OtherStore] })`
38
+ - **Mock values** — `builder.add(JobsApi, { useValue: mockApi })` for mock dependencies
39
+ - **`waitFor` helper** — wait for MobX observable conditions: `await waitFor(() => store.items.initialized)`
40
+
41
+ ## Stores with Global Clients
42
+
43
+ Queries using `globalClient` create shared instances on `window`, which breaks test isolation. Use `useClass` to swap in a local version that strips `globalClient`, or `useValue` to mock the store entirely:
44
+
45
+ ```tsx
46
+ // useClass — keeps real store logic, removes global client
47
+ @queryStore
48
+ class JobsLocalStore extends JobsStore {
49
+ @override
50
+ get jobsOptions(): QueryApiOptions<Job[]> {
51
+ return { ...super.jobsOptions, globalClient: undefined };
52
+ }
53
+ }
54
+
55
+ const { initialize } = new ContainerBuilder()
56
+ .add(JobsStore, { useClass: JobsLocalStore })
57
+ .build();
58
+ ```
59
+
60
+ ```tsx
61
+ // useValue — mock the store shape directly
62
+ const { initialize } = new ContainerBuilder()
63
+ .add(JobsStore, { useValue: { jobs: { data: mockJobs } } })
64
+ .build();
65
+ ```
66
+
67
+ ## Full Testing Guide
68
+
69
+ See the complete [Testing Guide](https://github.com/servicetitan/anvil-uikit-contrib/blob/master/packages/tanstack-query-mobx/src/test/TESTING.md) for:
70
+ - `ContainerBuilder` full API
71
+ - Skipping queries and disabled query handling
72
+ - Queries dependent on external data
73
+ - Mock values and `useClass` / `useValue` patterns
74
+ - Manual setup without `ContainerBuilder`
75
+ - Stores with global clients
@@ -1,5 +1,5 @@
1
1
  ---
2
- title: TanStack Query MobX Integration
2
+ title: Examples
3
3
  ---
4
4
 
5
5
  import {
@@ -8,36 +8,32 @@ import {
8
8
 
9
9
  import { CodeDemo } from '@site/src/components/code-demo';
10
10
 
11
- `@servicetitan/tanstack-query-mobx` contains integrations for using TanStack Query inside MobX stores.
11
+ # Interactive Examples
12
12
 
13
- Documentation for setup and use can be seen [here](https://docs.google.com/document/d/1MfFMtwF4Rb3rj076GWJSgNiA9PtfVThlZBNK4frZHrs/edit?usp=drive_link)
14
-
15
- ## Examples
16
-
17
- ### App QueryClient
13
+ ## App QueryClient
18
14
 
19
15
  <CodeDemo example={AppExample} srcPath="tanstack-query-mobx/src/demo/app-example.tsx" />
20
16
 
21
- ### Page QueryClient
17
+ ## Page QueryClient
22
18
 
23
19
  <CodeDemo example={PageExample} srcPath="tanstack-query-mobx/src/demo/page-example.tsx" />
24
20
 
25
- ### Initial Data
21
+ ## Initial Data
26
22
 
27
- #### No Initial Data
23
+ ### No Initial Data
28
24
 
29
25
  <CodeDemo example={NoInitialDataExample} srcPath="tanstack-query-mobx/src/demo/no-initial-data-example.tsx" />
30
26
 
31
- #### Has Initial Data
27
+ ### Has Initial Data
32
28
 
33
29
  <CodeDemo example={HasInitialDataExample} srcPath="tanstack-query-mobx/src/demo/has-initial-data-example.tsx" />
34
30
 
35
- ### Stale Time
31
+ ## Stale Time
36
32
 
37
- #### Stale Time: 0
33
+ ### Stale Time: 0
38
34
 
39
35
  <CodeDemo example={StaleTime0Example} srcPath="tanstack-query-mobx/src/demo/stale-time-0-example.tsx" />
40
36
 
41
- #### Stale Time: 10 Minutes
37
+ ### Stale Time: 10 Minutes
42
38
 
43
39
  <CodeDemo example={StaleTime10MinutesExample} srcPath="tanstack-query-mobx/src/demo/stale-time-10-minutes-example.tsx" />
@@ -0,0 +1,119 @@
1
+ ---
2
+ title: TanStack Query MobX
3
+ ---
4
+
5
+ # TanStack Query MobX Integration
6
+
7
+ `@servicetitan/tanstack-query-mobx` integrates [TanStack Query](https://tanstack.com/query/latest) with MobX stores and `@servicetitan/react-ioc` dependency injection. It provides declarative data fetching, caching, automatic query deduplication, and observable state management.
8
+
9
+ TanStack Query does not provide a query mechanism — you supply an async function using fetch, Axios, or the generated API functions. TanStack Query handles caching, retries, deduplication, refetching, and state.
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ npm install @servicetitan/tanstack-query-mobx
15
+ ```
16
+
17
+ ## Quick Start
18
+
19
+ ### 1. Provide the Query Client
20
+
21
+ A `QueryClientStore` is required at the top store level. It creates the TanStack `QueryClient` with sensible defaults (10-minute stale time, 3 retries, no refetch on window focus).
22
+
23
+ ```tsx
24
+ import { getQueryClient } from '@servicetitan/tanstack-query-mobx/composable';
25
+
26
+ export const App: FC = provide({
27
+ singletons: [getQueryClient()],
28
+ })(observer(() => <YourApp />));
29
+ ```
30
+
31
+ ### 2. Create a Store
32
+
33
+ Two approaches — both use the same query client.
34
+
35
+ #### Composable with `@queryStore` (recommended)
36
+
37
+ The `@queryStore` decorator + `query()`/`mutation()` factories. All queries are class fields with consistent access patterns. Favors composition over inheritance.
38
+
39
+ ```tsx
40
+ import { queryStore, query, mutation } from '@servicetitan/tanstack-query-mobx/composable';
41
+
42
+ @queryStore
43
+ class JobsStore extends Store {
44
+ @inject(JobsApi) private api?: JobsApi;
45
+ @observable selectedJobId = 0;
46
+
47
+ jobs = query<Job[]>(() => ({
48
+ queryKey: ['scheduling', 'jobs'],
49
+ queryFn: async () => (await this.api?.getJobs())?.data ?? [],
50
+ }));
51
+
52
+ jobDetails = query<JobDetails>(() => ({
53
+ queryKey: ['scheduling', 'job', this.selectedJobId],
54
+ queryFn: async () => (await this.api?.getJob(this.selectedJobId))?.data,
55
+ enabled: this.selectedJobId > 0,
56
+ }));
57
+
58
+ deleteJob = mutation<void, { id: number }>(() => ({
59
+ mutationFn: async (arg) => await this.api?.deleteJob(arg.id),
60
+ invalidatedQueries: [['scheduling', 'jobs']],
61
+ }));
62
+ }
63
+ ```
64
+
65
+ **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.
66
+
67
+ **What `@queryStore` handles automatically:**
68
+ - Applies `@injectable()` (no separate decorator needed)
69
+ - Injects `QueryClientStore` (no `@inject(QueryClientStore)` needed)
70
+ - Auto-discovers `QueryApi` and `MutationApi` properties and wires `setup()`/`dispose()`
71
+ - Chains consumer `initialize()` and `dispose()` methods
72
+ - Safe with deep inheritance — prevents double setup/dispose
73
+
74
+ #### Inheritance with `QueryApiStore`
75
+
76
+ Extend `QueryApiStore<T>` with a primary query via `queryOptions` getter, plus optional secondary queries via `addQuery()`.
77
+
78
+ ```tsx
79
+ import { QueryApiStore, QueryApiOptions } from '@servicetitan/tanstack-query-mobx';
80
+
81
+ @injectable()
82
+ class JobsStore extends QueryApiStore<Job[]> {
83
+ @inject(JobsApi) private api?: JobsApi;
84
+
85
+ get queryOptions(): QueryApiOptions<Job[]> {
86
+ return {
87
+ queryKey: ['scheduling', 'jobs'],
88
+ queryFn: async () => (await this.api?.getJobs())?.data ?? [],
89
+ };
90
+ }
91
+
92
+ deleteJob = this.addMutation<void, { id: number }>(() => ({
93
+ mutationFn: async (arg) => await this.api?.deleteJob(arg.id),
94
+ invalidatedQueries: [['scheduling', 'jobs']],
95
+ }));
96
+ }
97
+ ```
98
+
99
+ ### 3. Use in Components
100
+
101
+ ```tsx
102
+ const [store] = useDependencies(JobsStore);
103
+
104
+ // Composable — access via named query
105
+ const { data, isLoading } = store.jobs;
106
+
107
+ // Inheritance — primary query proxied to store
108
+ const { data, isLoading } = store;
109
+ ```
110
+
111
+ ## How It Works
112
+
113
+ TanStack Query's main engine is the **query client**. Stores subscribe to the client using composite keys. When a store initializes, it subscribes its queries to the client. When it disposes, it unsubscribes.
114
+
115
+ - **Queries** are data-driven — they run automatically based on their options. The developer doesn't call them directly. Changes to `queryKey` values trigger refetches via MobX reactivity.
116
+ - **Mutations** are called manually via `runMutation()`. They can automatically invalidate (refetch) related queries after success.
117
+ - **Caching** is per query key. Multiple stores with the same key share one cache and one network request (deduplication).
118
+
119
+ The `QueryClientStore` creates and manages the TanStack `QueryClient`. The `@queryStore` decorator (or `QueryApiStore` base class) subscribes queries to this client during the react-ioc `initialize()` lifecycle and unsubscribes during `dispose()`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@servicetitan/docs-anvil-uikit-contrib",
3
- "version": "38.7.0",
3
+ "version": "39.0.0",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -16,5 +16,5 @@
16
16
  "cli": {
17
17
  "webpack": false
18
18
  },
19
- "gitHead": "294a6fa2cafd00b82d6a7e0f5f9bea6acab9b2b6"
19
+ "gitHead": "16cf24bc08b7dde828c65180aa53d7a2b33a33d9"
20
20
  }