Signals for managing, caching and syncing asynchronous and remote data in Angular
—
Type-safe utility functions for creating reusable query and mutation options with proper type inference and sharing across components.
Type-safe helper for sharing and reusing query options with automatic type tagging.
/**
* Allows to share and re-use query options in a type-safe way.
* The queryKey will be tagged with the type from queryFn.
* @param options - The query options to tag with the type from queryFn
* @returns The tagged query options
*/
function queryOptions<TQueryFnData, TError, TData, TQueryKey>(
options: CreateQueryOptions<TQueryFnData, TError, TData, TQueryKey>
): CreateQueryOptions<TQueryFnData, TError, TData, TQueryKey> & {
queryKey: DataTag<TQueryKey, TQueryFnData, TError>;
};
// Overloads for different initial data scenarios
function queryOptions<TQueryFnData, TError, TData, TQueryKey>(
options: DefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey>
): DefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey> & {
queryKey: DataTag<TQueryKey, TQueryFnData, TError>;
};
function queryOptions<TQueryFnData, TError, TData, TQueryKey>(
options: UndefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey>
): UndefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey> & {
queryKey: DataTag<TQueryKey, TQueryFnData, TError>;
};Usage Examples:
import { queryOptions, injectQuery, QueryClient } from "@tanstack/angular-query-experimental";
import { Injectable, inject } from "@angular/core";
import { HttpClient } from "@angular/common/http";
// Define reusable query options
@Injectable({ providedIn: 'root' })
export class UserQueriesService {
#http = inject(HttpClient);
userById(id: number) {
return queryOptions({
queryKey: ['user', id] as const,
queryFn: () => this.#http.get<User>(`/api/users/${id}`),
staleTime: 5 * 60 * 1000, // 5 minutes
retry: 1
});
}
userPosts(userId: number) {
return queryOptions({
queryKey: ['posts', 'user', userId] as const,
queryFn: () => this.#http.get<Post[]>(`/api/users/${userId}/posts`),
staleTime: 2 * 60 * 1000 // 2 minutes
});
}
currentUser() {
return queryOptions({
queryKey: ['user', 'current'] as const,
queryFn: () => this.#http.get<User>('/api/user/current'),
staleTime: 10 * 60 * 1000, // 10 minutes
retry: 2
});
}
}
// Use in components
@Component({
selector: 'app-user-profile',
template: `
<div *ngIf="userQuery.isSuccess()">
<h1>{{ userQuery.data()?.name }}</h1>
<p>{{ userQuery.data()?.email }}</p>
</div>
<div *ngIf="postsQuery.isSuccess()">
<h2>Posts</h2>
<div *ngFor="let post of postsQuery.data()">
{{ post.title }}
</div>
</div>
`
})
export class UserProfileComponent {
#queries = inject(UserQueriesService);
#queryClient = inject(QueryClient);
userId = signal(1);
// Type-safe query usage
userQuery = injectQuery(() => this.#queries.userById(this.userId()));
postsQuery = injectQuery(() => this.#queries.userPosts(this.userId()));
// Can also use the tagged queryKey for manual operations
refreshUser() {
const options = this.#queries.userById(this.userId());
this.#queryClient.invalidateQueries({ queryKey: options.queryKey });
}
// Type inference works correctly
getUserData() {
const options = this.#queries.userById(this.userId());
return this.#queryClient.getQueryData(options.queryKey); // Type: User | undefined
}
}
interface User {
id: number;
name: string;
email: string;
}
interface Post {
id: number;
title: string;
content: string;
}Type-safe helper for sharing and reusing infinite query options with automatic type tagging.
/**
* Allows to share and re-use infinite query options in a type-safe way.
* The queryKey will be tagged with the type from queryFn.
* @param options - The infinite query options to tag with the type from queryFn
* @returns The tagged infinite query options
*/
function infiniteQueryOptions<TQueryFnData, TError, TData, TQueryKey, TPageParam>(
options: CreateInfiniteQueryOptions<TQueryFnData, TError, TData, TQueryKey, TPageParam>
): CreateInfiniteQueryOptions<TQueryFnData, TError, TData, TQueryKey, TPageParam> & {
queryKey: DataTag<TQueryKey, InfiniteData<TQueryFnData>, TError>;
};
// Overloads for different initial data scenarios
function infiniteQueryOptions<TQueryFnData, TError, TData, TQueryKey, TPageParam>(
options: DefinedInitialDataInfiniteOptions<TQueryFnData, TError, TData, TQueryKey, TPageParam>
): DefinedInitialDataInfiniteOptions<TQueryFnData, TError, TData, TQueryKey, TPageParam> & {
queryKey: DataTag<TQueryKey, InfiniteData<TQueryFnData>, TError>;
};
function infiniteQueryOptions<TQueryFnData, TError, TData, TQueryKey, TPageParam>(
options: UndefinedInitialDataInfiniteOptions<TQueryFnData, TError, TData, TQueryKey, TPageParam>
): UndefinedInitialDataInfiniteOptions<TQueryFnData, TError, TData, TQueryKey, TPageParam> & {
queryKey: DataTag<TQueryKey, InfiniteData<TQueryFnData>, TError>;
};Usage Examples:
import { infiniteQueryOptions, injectInfiniteQuery } from "@tanstack/angular-query-experimental";
@Injectable({ providedIn: 'root' })
export class PostQueriesService {
#http = inject(HttpClient);
allPosts() {
return infiniteQueryOptions({
queryKey: ['posts', 'infinite'] as const,
queryFn: ({ pageParam = 1 }) =>
this.#http.get<PostsPage>(`/api/posts?page=${pageParam}&limit=10`),
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.hasMore ? lastPage.nextPage : null,
staleTime: 5 * 60 * 1000
});
}
postsByCategory(category: string) {
return infiniteQueryOptions({
queryKey: ['posts', 'category', category] as const,
queryFn: ({ pageParam = 1 }) =>
this.#http.get<PostsPage>(`/api/posts?category=${category}&page=${pageParam}`),
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.hasMore ? lastPage.nextPage : null
});
}
}
@Component({})
export class PostsListComponent {
#queries = inject(PostQueriesService);
category = signal('technology');
postsQuery = injectInfiniteQuery(() =>
this.#queries.postsByCategory(this.category())
);
}Type-safe helper for sharing and reusing mutation options.
/**
* Allows to share and re-use mutation options in a type-safe way.
* @param options - The mutation options
* @returns Mutation options
*/
function mutationOptions<TData, TError, TVariables, TContext>(
options: CreateMutationOptions<TData, TError, TVariables, TContext>
): CreateMutationOptions<TData, TError, TVariables, TContext>;
// Overload for mutations with mutation key
function mutationOptions<TData, TError, TVariables, TContext>(
options: WithRequired<CreateMutationOptions<TData, TError, TVariables, TContext>, 'mutationKey'>
): WithRequired<CreateMutationOptions<TData, TError, TVariables, TContext>, 'mutationKey'>;
// Overload for mutations without mutation key
function mutationOptions<TData, TError, TVariables, TContext>(
options: Omit<CreateMutationOptions<TData, TError, TVariables, TContext>, 'mutationKey'>
): Omit<CreateMutationOptions<TData, TError, TVariables, TContext>, 'mutationKey'>;Usage Examples:
import { mutationOptions, injectMutation, QueryClient } from "@tanstack/angular-query-experimental";
@Injectable({ providedIn: 'root' })
export class UserMutationsService {
#http = inject(HttpClient);
#queryClient = inject(QueryClient);
createUser() {
return mutationOptions({
mutationKey: ['user', 'create'] as const,
mutationFn: (userData: CreateUserRequest) =>
this.#http.post<User>('/api/users', userData),
onSuccess: (newUser) => {
// Invalidate user lists
this.#queryClient.invalidateQueries({ queryKey: ['users'] });
// Set individual user data
this.#queryClient.setQueryData(['user', newUser.id], newUser);
},
onError: (error) => {
console.error('Failed to create user:', error);
}
});
}
updateUser(id: number) {
return mutationOptions({
mutationKey: ['user', 'update', id] as const,
mutationFn: (updates: Partial<User>) =>
this.#http.patch<User>(`/api/users/${id}`, updates),
onMutate: async (variables) => {
// Optimistic update
await this.#queryClient.cancelQueries({ queryKey: ['user', id] });
const previousUser = this.#queryClient.getQueryData<User>(['user', id]);
this.#queryClient.setQueryData(['user', id], (old: User) => ({
...old,
...variables
}));
return { previousUser };
},
onError: (error, variables, context) => {
// Rollback on error
if (context?.previousUser) {
this.#queryClient.setQueryData(['user', id], context.previousUser);
}
},
onSettled: () => {
this.#queryClient.invalidateQueries({ queryKey: ['user', id] });
}
});
}
deleteUser(id: number) {
return mutationOptions({
mutationKey: ['user', 'delete', id] as const,
mutationFn: () => this.#http.delete(`/api/users/${id}`),
onSuccess: () => {
// Remove from cache
this.#queryClient.removeQueries({ queryKey: ['user', id] });
// Invalidate user lists
this.#queryClient.invalidateQueries({ queryKey: ['users'] });
}
});
}
}
@Component({
selector: 'app-user-form',
template: `
<form (ngSubmit)="handleSubmit()">
<input [(ngModel)]="name" placeholder="Name" />
<input [(ngModel)]="email" placeholder="Email" />
<button
type="submit"
[disabled]="createMutation.isPending()"
>
{{ createMutation.isPending() ? 'Creating...' : 'Create User' }}
</button>
<button
*ngIf="editingUser()"
type="button"
(click)="handleUpdate()"
[disabled]="updateMutation.isPending()"
>
Update
</button>
</form>
`
})
export class UserFormComponent {
#mutations = inject(UserMutationsService);
name = '';
email = '';
editingUser = signal<User | null>(null);
createMutation = injectMutation(() => this.#mutations.createUser());
updateMutation = injectMutation(() => {
const user = this.editingUser();
return user ? this.#mutations.updateUser(user.id) : this.#mutations.createUser();
});
handleSubmit() {
this.createMutation.mutate({
name: this.name,
email: this.email
});
}
handleUpdate() {
this.updateMutation.mutate({
name: this.name,
email: this.email
});
}
}@Injectable({ providedIn: 'root' })
export class DataQueriesService {
#http = inject(HttpClient);
// Factory function for paginated data
paginatedData<T>(
endpoint: string,
config: { pageSize?: number; staleTime?: number } = {}
) {
const { pageSize = 20, staleTime = 5 * 60 * 1000 } = config;
return queryOptions({
queryKey: ['paginated', endpoint, { pageSize }] as const,
queryFn: ({ pageParam = 1 }) =>
this.#http.get<PaginatedResponse<T>>(`${endpoint}?page=${pageParam}&limit=${pageSize}`),
staleTime
});
}
// Factory for filtered queries
filteredQuery<T>(
endpoint: string,
filters: Record<string, any>,
config: { staleTime?: number } = {}
) {
return queryOptions({
queryKey: ['filtered', endpoint, filters] as const,
queryFn: () => {
const params = new URLSearchParams();
Object.entries(filters).forEach(([key, value]) => {
if (value != null) params.set(key, String(value));
});
return this.#http.get<T>(`${endpoint}?${params}`);
},
staleTime: config.staleTime || 2 * 60 * 1000
});
}
}
// Usage
@Component({})
export class ProductsComponent {
#queries = inject(DataQueriesService);
searchFilters = signal({ category: 'electronics', minPrice: 100 });
productsQuery = injectQuery(() =>
this.#queries.filteredQuery<Product[]>('/api/products', this.searchFilters())
);
}@Injectable({ providedIn: 'root' })
export class ConditionalQueriesService {
#http = inject(HttpClient);
userData(userId: number, includePrivate: boolean = false) {
return queryOptions({
queryKey: ['user', userId, { includePrivate }] as const,
queryFn: () => {
const endpoint = includePrivate
? `/api/users/${userId}/private`
: `/api/users/${userId}`;
return this.#http.get<User>(endpoint);
},
staleTime: includePrivate ? 1 * 60 * 1000 : 10 * 60 * 1000, // Private data stales faster
retry: includePrivate ? 0 : 1 // Don't retry private data requests
});
}
searchData(query: string, options: SearchOptions = {}) {
return queryOptions({
queryKey: ['search', query, options] as const,
queryFn: () => this.#http.post<SearchResult[]>('/api/search', { query, ...options }),
enabled: query.length > 2,
staleTime: options.realtime ? 0 : 5 * 60 * 1000
});
}
}@Injectable({ providedIn: 'root' })
export class ComposedQueriesService {
#http = inject(HttpClient);
// Base query options
private baseQueryOptions = {
staleTime: 5 * 60 * 1000,
retry: 1,
retryDelay: (attemptIndex: number) => Math.min(1000 * 2 ** attemptIndex, 30000)
};
// Composed with additional options
criticalData(id: string) {
return queryOptions({
...this.baseQueryOptions,
queryKey: ['critical', id] as const,
queryFn: () => this.#http.get<CriticalData>(`/api/critical/${id}`),
retry: 3, // Override base retry
refetchOnWindowFocus: true
});
}
// Composed for real-time data
realtimeData(channel: string) {
return queryOptions({
...this.baseQueryOptions,
queryKey: ['realtime', channel] as const,
queryFn: () => this.#http.get<RealtimeData>(`/api/realtime/${channel}`),
staleTime: 0, // Override base staleTime
refetchInterval: 5000
});
}
}// test-queries.service.ts
@Injectable()
export class TestQueriesService {
mockUserById(id: number, userData: User) {
return queryOptions({
queryKey: ['user', id] as const,
queryFn: () => Promise.resolve(userData),
staleTime: Infinity // Never stale in tests
});
}
mockFailingQuery<T>(errorMessage: string) {
return queryOptions({
queryKey: ['failing'] as const,
queryFn: (): Promise<T> => Promise.reject(new Error(errorMessage)),
retry: false
});
}
}
// In tests
describe('UserComponent', () => {
let testQueries: TestQueriesService;
beforeEach(() => {
testQueries = TestBed.inject(TestQueriesService);
});
it('should display user data', () => {
const mockUser = { id: 1, name: 'Test User', email: 'test@example.com' };
// Use mock query options
component.userQuery = injectQuery(() => testQueries.mockUserById(1, mockUser));
expect(component.userQuery.data()).toEqual(mockUser);
});
});Install with Tessl CLI
npx tessl i tessl/npm-tanstack--angular-query-experimental