0
# Options Helpers
1
2
Type-safe utility functions for creating reusable query and mutation options with proper type inference and sharing across components.
3
4
## Capabilities
5
6
### Query Options
7
8
Type-safe helper for sharing and reusing query options with automatic type tagging.
9
10
```typescript { .api }
11
/**
12
* Allows to share and re-use query options in a type-safe way.
13
* The queryKey will be tagged with the type from queryFn.
14
* @param options - The query options to tag with the type from queryFn
15
* @returns The tagged query options
16
*/
17
function queryOptions<TQueryFnData, TError, TData, TQueryKey>(
18
options: CreateQueryOptions<TQueryFnData, TError, TData, TQueryKey>
19
): CreateQueryOptions<TQueryFnData, TError, TData, TQueryKey> & {
20
queryKey: DataTag<TQueryKey, TQueryFnData, TError>;
21
};
22
23
// Overloads for different initial data scenarios
24
function queryOptions<TQueryFnData, TError, TData, TQueryKey>(
25
options: DefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey>
26
): DefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey> & {
27
queryKey: DataTag<TQueryKey, TQueryFnData, TError>;
28
};
29
30
function queryOptions<TQueryFnData, TError, TData, TQueryKey>(
31
options: UndefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey>
32
): UndefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey> & {
33
queryKey: DataTag<TQueryKey, TQueryFnData, TError>;
34
};
35
```
36
37
**Usage Examples:**
38
39
```typescript
40
import { queryOptions, injectQuery, QueryClient } from "@tanstack/angular-query-experimental";
41
import { Injectable, inject } from "@angular/core";
42
import { HttpClient } from "@angular/common/http";
43
44
// Define reusable query options
45
@Injectable({ providedIn: 'root' })
46
export class UserQueriesService {
47
#http = inject(HttpClient);
48
49
userById(id: number) {
50
return queryOptions({
51
queryKey: ['user', id] as const,
52
queryFn: () => this.#http.get<User>(`/api/users/${id}`),
53
staleTime: 5 * 60 * 1000, // 5 minutes
54
retry: 1
55
});
56
}
57
58
userPosts(userId: number) {
59
return queryOptions({
60
queryKey: ['posts', 'user', userId] as const,
61
queryFn: () => this.#http.get<Post[]>(`/api/users/${userId}/posts`),
62
staleTime: 2 * 60 * 1000 // 2 minutes
63
});
64
}
65
66
currentUser() {
67
return queryOptions({
68
queryKey: ['user', 'current'] as const,
69
queryFn: () => this.#http.get<User>('/api/user/current'),
70
staleTime: 10 * 60 * 1000, // 10 minutes
71
retry: 2
72
});
73
}
74
}
75
76
// Use in components
77
@Component({
78
selector: 'app-user-profile',
79
template: `
80
<div *ngIf="userQuery.isSuccess()">
81
<h1>{{ userQuery.data()?.name }}</h1>
82
<p>{{ userQuery.data()?.email }}</p>
83
</div>
84
85
<div *ngIf="postsQuery.isSuccess()">
86
<h2>Posts</h2>
87
<div *ngFor="let post of postsQuery.data()">
88
{{ post.title }}
89
</div>
90
</div>
91
`
92
})
93
export class UserProfileComponent {
94
#queries = inject(UserQueriesService);
95
#queryClient = inject(QueryClient);
96
97
userId = signal(1);
98
99
// Type-safe query usage
100
userQuery = injectQuery(() => this.#queries.userById(this.userId()));
101
postsQuery = injectQuery(() => this.#queries.userPosts(this.userId()));
102
103
// Can also use the tagged queryKey for manual operations
104
refreshUser() {
105
const options = this.#queries.userById(this.userId());
106
this.#queryClient.invalidateQueries({ queryKey: options.queryKey });
107
}
108
109
// Type inference works correctly
110
getUserData() {
111
const options = this.#queries.userById(this.userId());
112
return this.#queryClient.getQueryData(options.queryKey); // Type: User | undefined
113
}
114
}
115
116
interface User {
117
id: number;
118
name: string;
119
email: string;
120
}
121
122
interface Post {
123
id: number;
124
title: string;
125
content: string;
126
}
127
```
128
129
### Infinite Query Options
130
131
Type-safe helper for sharing and reusing infinite query options with automatic type tagging.
132
133
```typescript { .api }
134
/**
135
* Allows to share and re-use infinite query options in a type-safe way.
136
* The queryKey will be tagged with the type from queryFn.
137
* @param options - The infinite query options to tag with the type from queryFn
138
* @returns The tagged infinite query options
139
*/
140
function infiniteQueryOptions<TQueryFnData, TError, TData, TQueryKey, TPageParam>(
141
options: CreateInfiniteQueryOptions<TQueryFnData, TError, TData, TQueryKey, TPageParam>
142
): CreateInfiniteQueryOptions<TQueryFnData, TError, TData, TQueryKey, TPageParam> & {
143
queryKey: DataTag<TQueryKey, InfiniteData<TQueryFnData>, TError>;
144
};
145
146
// Overloads for different initial data scenarios
147
function infiniteQueryOptions<TQueryFnData, TError, TData, TQueryKey, TPageParam>(
148
options: DefinedInitialDataInfiniteOptions<TQueryFnData, TError, TData, TQueryKey, TPageParam>
149
): DefinedInitialDataInfiniteOptions<TQueryFnData, TError, TData, TQueryKey, TPageParam> & {
150
queryKey: DataTag<TQueryKey, InfiniteData<TQueryFnData>, TError>;
151
};
152
153
function infiniteQueryOptions<TQueryFnData, TError, TData, TQueryKey, TPageParam>(
154
options: UndefinedInitialDataInfiniteOptions<TQueryFnData, TError, TData, TQueryKey, TPageParam>
155
): UndefinedInitialDataInfiniteOptions<TQueryFnData, TError, TData, TQueryKey, TPageParam> & {
156
queryKey: DataTag<TQueryKey, InfiniteData<TQueryFnData>, TError>;
157
};
158
```
159
160
**Usage Examples:**
161
162
```typescript
163
import { infiniteQueryOptions, injectInfiniteQuery } from "@tanstack/angular-query-experimental";
164
165
@Injectable({ providedIn: 'root' })
166
export class PostQueriesService {
167
#http = inject(HttpClient);
168
169
allPosts() {
170
return infiniteQueryOptions({
171
queryKey: ['posts', 'infinite'] as const,
172
queryFn: ({ pageParam = 1 }) =>
173
this.#http.get<PostsPage>(`/api/posts?page=${pageParam}&limit=10`),
174
initialPageParam: 1,
175
getNextPageParam: (lastPage) =>
176
lastPage.hasMore ? lastPage.nextPage : null,
177
staleTime: 5 * 60 * 1000
178
});
179
}
180
181
postsByCategory(category: string) {
182
return infiniteQueryOptions({
183
queryKey: ['posts', 'category', category] as const,
184
queryFn: ({ pageParam = 1 }) =>
185
this.#http.get<PostsPage>(`/api/posts?category=${category}&page=${pageParam}`),
186
initialPageParam: 1,
187
getNextPageParam: (lastPage) =>
188
lastPage.hasMore ? lastPage.nextPage : null
189
});
190
}
191
}
192
193
@Component({})
194
export class PostsListComponent {
195
#queries = inject(PostQueriesService);
196
197
category = signal('technology');
198
199
postsQuery = injectInfiniteQuery(() =>
200
this.#queries.postsByCategory(this.category())
201
);
202
}
203
```
204
205
### Mutation Options
206
207
Type-safe helper for sharing and reusing mutation options.
208
209
```typescript { .api }
210
/**
211
* Allows to share and re-use mutation options in a type-safe way.
212
* @param options - The mutation options
213
* @returns Mutation options
214
*/
215
function mutationOptions<TData, TError, TVariables, TContext>(
216
options: CreateMutationOptions<TData, TError, TVariables, TContext>
217
): CreateMutationOptions<TData, TError, TVariables, TContext>;
218
219
// Overload for mutations with mutation key
220
function mutationOptions<TData, TError, TVariables, TContext>(
221
options: WithRequired<CreateMutationOptions<TData, TError, TVariables, TContext>, 'mutationKey'>
222
): WithRequired<CreateMutationOptions<TData, TError, TVariables, TContext>, 'mutationKey'>;
223
224
// Overload for mutations without mutation key
225
function mutationOptions<TData, TError, TVariables, TContext>(
226
options: Omit<CreateMutationOptions<TData, TError, TVariables, TContext>, 'mutationKey'>
227
): Omit<CreateMutationOptions<TData, TError, TVariables, TContext>, 'mutationKey'>;
228
```
229
230
**Usage Examples:**
231
232
```typescript
233
import { mutationOptions, injectMutation, QueryClient } from "@tanstack/angular-query-experimental";
234
235
@Injectable({ providedIn: 'root' })
236
export class UserMutationsService {
237
#http = inject(HttpClient);
238
#queryClient = inject(QueryClient);
239
240
createUser() {
241
return mutationOptions({
242
mutationKey: ['user', 'create'] as const,
243
mutationFn: (userData: CreateUserRequest) =>
244
this.#http.post<User>('/api/users', userData),
245
onSuccess: (newUser) => {
246
// Invalidate user lists
247
this.#queryClient.invalidateQueries({ queryKey: ['users'] });
248
// Set individual user data
249
this.#queryClient.setQueryData(['user', newUser.id], newUser);
250
},
251
onError: (error) => {
252
console.error('Failed to create user:', error);
253
}
254
});
255
}
256
257
updateUser(id: number) {
258
return mutationOptions({
259
mutationKey: ['user', 'update', id] as const,
260
mutationFn: (updates: Partial<User>) =>
261
this.#http.patch<User>(`/api/users/${id}`, updates),
262
onMutate: async (variables) => {
263
// Optimistic update
264
await this.#queryClient.cancelQueries({ queryKey: ['user', id] });
265
const previousUser = this.#queryClient.getQueryData<User>(['user', id]);
266
267
this.#queryClient.setQueryData(['user', id], (old: User) => ({
268
...old,
269
...variables
270
}));
271
272
return { previousUser };
273
},
274
onError: (error, variables, context) => {
275
// Rollback on error
276
if (context?.previousUser) {
277
this.#queryClient.setQueryData(['user', id], context.previousUser);
278
}
279
},
280
onSettled: () => {
281
this.#queryClient.invalidateQueries({ queryKey: ['user', id] });
282
}
283
});
284
}
285
286
deleteUser(id: number) {
287
return mutationOptions({
288
mutationKey: ['user', 'delete', id] as const,
289
mutationFn: () => this.#http.delete(`/api/users/${id}`),
290
onSuccess: () => {
291
// Remove from cache
292
this.#queryClient.removeQueries({ queryKey: ['user', id] });
293
// Invalidate user lists
294
this.#queryClient.invalidateQueries({ queryKey: ['users'] });
295
}
296
});
297
}
298
}
299
300
@Component({
301
selector: 'app-user-form',
302
template: `
303
<form (ngSubmit)="handleSubmit()">
304
<input [(ngModel)]="name" placeholder="Name" />
305
<input [(ngModel)]="email" placeholder="Email" />
306
307
<button
308
type="submit"
309
[disabled]="createMutation.isPending()"
310
>
311
{{ createMutation.isPending() ? 'Creating...' : 'Create User' }}
312
</button>
313
314
<button
315
*ngIf="editingUser()"
316
type="button"
317
(click)="handleUpdate()"
318
[disabled]="updateMutation.isPending()"
319
>
320
Update
321
</button>
322
</form>
323
`
324
})
325
export class UserFormComponent {
326
#mutations = inject(UserMutationsService);
327
328
name = '';
329
email = '';
330
editingUser = signal<User | null>(null);
331
332
createMutation = injectMutation(() => this.#mutations.createUser());
333
334
updateMutation = injectMutation(() => {
335
const user = this.editingUser();
336
return user ? this.#mutations.updateUser(user.id) : this.#mutations.createUser();
337
});
338
339
handleSubmit() {
340
this.createMutation.mutate({
341
name: this.name,
342
email: this.email
343
});
344
}
345
346
handleUpdate() {
347
this.updateMutation.mutate({
348
name: this.name,
349
email: this.email
350
});
351
}
352
}
353
```
354
355
## Advanced Usage Patterns
356
357
### Factory Functions with Parameters
358
359
```typescript
360
@Injectable({ providedIn: 'root' })
361
export class DataQueriesService {
362
#http = inject(HttpClient);
363
364
// Factory function for paginated data
365
paginatedData<T>(
366
endpoint: string,
367
config: { pageSize?: number; staleTime?: number } = {}
368
) {
369
const { pageSize = 20, staleTime = 5 * 60 * 1000 } = config;
370
371
return queryOptions({
372
queryKey: ['paginated', endpoint, { pageSize }] as const,
373
queryFn: ({ pageParam = 1 }) =>
374
this.#http.get<PaginatedResponse<T>>(`${endpoint}?page=${pageParam}&limit=${pageSize}`),
375
staleTime
376
});
377
}
378
379
// Factory for filtered queries
380
filteredQuery<T>(
381
endpoint: string,
382
filters: Record<string, any>,
383
config: { staleTime?: number } = {}
384
) {
385
return queryOptions({
386
queryKey: ['filtered', endpoint, filters] as const,
387
queryFn: () => {
388
const params = new URLSearchParams();
389
Object.entries(filters).forEach(([key, value]) => {
390
if (value != null) params.set(key, String(value));
391
});
392
return this.#http.get<T>(`${endpoint}?${params}`);
393
},
394
staleTime: config.staleTime || 2 * 60 * 1000
395
});
396
}
397
}
398
399
// Usage
400
@Component({})
401
export class ProductsComponent {
402
#queries = inject(DataQueriesService);
403
404
searchFilters = signal({ category: 'electronics', minPrice: 100 });
405
406
productsQuery = injectQuery(() =>
407
this.#queries.filteredQuery<Product[]>('/api/products', this.searchFilters())
408
);
409
}
410
```
411
412
### Conditional Options
413
414
```typescript
415
@Injectable({ providedIn: 'root' })
416
export class ConditionalQueriesService {
417
#http = inject(HttpClient);
418
419
userData(userId: number, includePrivate: boolean = false) {
420
return queryOptions({
421
queryKey: ['user', userId, { includePrivate }] as const,
422
queryFn: () => {
423
const endpoint = includePrivate
424
? `/api/users/${userId}/private`
425
: `/api/users/${userId}`;
426
return this.#http.get<User>(endpoint);
427
},
428
staleTime: includePrivate ? 1 * 60 * 1000 : 10 * 60 * 1000, // Private data stales faster
429
retry: includePrivate ? 0 : 1 // Don't retry private data requests
430
});
431
}
432
433
searchData(query: string, options: SearchOptions = {}) {
434
return queryOptions({
435
queryKey: ['search', query, options] as const,
436
queryFn: () => this.#http.post<SearchResult[]>('/api/search', { query, ...options }),
437
enabled: query.length > 2,
438
staleTime: options.realtime ? 0 : 5 * 60 * 1000
439
});
440
}
441
}
442
```
443
444
### Composition Patterns
445
446
```typescript
447
@Injectable({ providedIn: 'root' })
448
export class ComposedQueriesService {
449
#http = inject(HttpClient);
450
451
// Base query options
452
private baseQueryOptions = {
453
staleTime: 5 * 60 * 1000,
454
retry: 1,
455
retryDelay: (attemptIndex: number) => Math.min(1000 * 2 ** attemptIndex, 30000)
456
};
457
458
// Composed with additional options
459
criticalData(id: string) {
460
return queryOptions({
461
...this.baseQueryOptions,
462
queryKey: ['critical', id] as const,
463
queryFn: () => this.#http.get<CriticalData>(`/api/critical/${id}`),
464
retry: 3, // Override base retry
465
refetchOnWindowFocus: true
466
});
467
}
468
469
// Composed for real-time data
470
realtimeData(channel: string) {
471
return queryOptions({
472
...this.baseQueryOptions,
473
queryKey: ['realtime', channel] as const,
474
queryFn: () => this.#http.get<RealtimeData>(`/api/realtime/${channel}`),
475
staleTime: 0, // Override base staleTime
476
refetchInterval: 5000
477
});
478
}
479
}
480
```
481
482
### Testing Helpers
483
484
```typescript
485
// test-queries.service.ts
486
@Injectable()
487
export class TestQueriesService {
488
mockUserById(id: number, userData: User) {
489
return queryOptions({
490
queryKey: ['user', id] as const,
491
queryFn: () => Promise.resolve(userData),
492
staleTime: Infinity // Never stale in tests
493
});
494
}
495
496
mockFailingQuery<T>(errorMessage: string) {
497
return queryOptions({
498
queryKey: ['failing'] as const,
499
queryFn: (): Promise<T> => Promise.reject(new Error(errorMessage)),
500
retry: false
501
});
502
}
503
}
504
505
// In tests
506
describe('UserComponent', () => {
507
let testQueries: TestQueriesService;
508
509
beforeEach(() => {
510
testQueries = TestBed.inject(TestQueriesService);
511
});
512
513
it('should display user data', () => {
514
const mockUser = { id: 1, name: 'Test User', email: 'test@example.com' };
515
516
// Use mock query options
517
component.userQuery = injectQuery(() => testQueries.mockUserById(1, mockUser));
518
519
expect(component.userQuery.data()).toEqual(mockUser);
520
});
521
});
522
```