CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl/npm-tanstack--angular-query-experimental

Signals for managing, caching and syncing asynchronous and remote data in Angular

Pending
Overview
Eval results
Files

options-helpers.mddocs/

Options Helpers

Type-safe utility functions for creating reusable query and mutation options with proper type inference and sharing across components.

Capabilities

Query Options

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;
}

Infinite Query Options

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())
  );
}

Mutation Options

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
    });
  }
}

Advanced Usage Patterns

Factory Functions with Parameters

@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())
  );
}

Conditional Options

@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
    });
  }
}

Composition Patterns

@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
    });
  }
}

Testing Helpers

// 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

docs

index.md

infinite-queries.md

multi-query-operations.md

mutation-management.md

options-helpers.md

provider-setup.md

query-management.md

status-monitoring.md

tile.json