Angular Signal Store Query: A Glimpse into the Future

Enter NgRx Signal Store and Tanstack Query—a powerful integration that simplifies state management and data fetching while embracing Angular's reactive programming philosophy, allowing to streamline workflows, reduce boilerplate, and improve developer productivity—all while enhancing application performance.

The State Management Dilemma

Frontend developers know the drill. You start with simple state management, and before you know it, you're drowning in complex reducers, endless action types, and convoluted data flows. RxJS simplifies reactivity, but it often adds its own layer of complexity.

What Makes This Solution Powerful

  • Reactive Signals: Angular's new primitive that changes the game
  • Declarative Data Fetching: Say goodbye to async complexity
  • Minimal Boilerplate: Write less, do more
  • Type Safety: Catch issues before they become problems

Implementation Deep Dive

I’ll walk you through how to implement this state management approach step by step.

Creating a Powerful Query Integration Utility

Here's the code that bridges Signal Store and TanStack Query:

import {
  type EmptyFeatureResult,
  signalStoreFeature,
  type SignalStoreFeature,
  type SignalStoreFeatureResult,
  withMethods,
  type Prettify,
  type StateSignals,
  type WritableStateSource,
} from '@ngrx/signals';

import {
  type CreateQueryOptions,
  type CreateQueryResult,
  injectQuery, type QueryKey 
} from '@tanstack/angular-query-experimental';

const lowerFirstLetter = <T extends string>(value: T): Uncapitalize<T> => {
  if (typeof value !== 'string') {
    return value;
  }

  const trimmed = value.trim();

  const first: string = trimmed.charAt(0).toLowerCase();
  const rest: string = trimmed.slice(1);

  return `${first}${rest}` as Uncapitalize<T>;
};

export type QueryStore<Input extends SignalStoreFeatureResult> = Prettify<
  StateSignals<Input['state']> & Input['computed'] & Input['methods'] & WritableStateSource<Prettify<Input['state']>>
>;

export type CreateQueryFn<
  TDataFn = unknown,
  TError = Error,
  TData = TDataFn,
  TQueryKey extends QueryKey = QueryKey,
  Input extends SignalStoreFeatureResult = SignalStoreFeatureResult,
> = (store: QueryStore<Input>) => () => CreateQueryOptions<TDataFn, TError, TData, TQueryKey>;

export type QueryProp<Name extends string> = `${Uncapitalize<Name>}TanstackQuery`;

export type QueryMethod<TData = unknown, TError = Error> = (() => CreateQueryResult<TData, TError>) &
  CreateQueryResult<TData, TError>;
export const withTanstackQuery = <
  Name extends string,
  TDataFn = unknown,
  TError = Error,
  TData = TDataFn,
  TQueryKey extends QueryKey = QueryKey,
  Input extends SignalStoreFeatureResult = SignalStoreFeatureResult,
>(
  name: Name,
  createQueryFn: CreateQueryFn<TDataFn, TError, TData, TQueryKey, NoInfer<Input>>,
): SignalStoreFeature<
  Input,
  EmptyFeatureResult & { methods: Record<QueryProp<NoInfer<Name>>, QueryMethod<NoInfer<TData>, NoInfer<TError>>> }
> => {
  const prop: QueryProp<NoInfer<Name>> = `${lowerFirstLetter(name)}TanstackQuery`;
  
  return signalStoreFeature(
    withMethods((store) => {
      const query = injectQuery(createQueryFn(store as QueryStore<NoInfer<Input>>));
      
      return {
        [prop]: new Proxy(() => query, {
          get: (_, prop) => Reflect.get(query, prop),
          has: (_, prop) => Reflect.has(query, prop),
        }),
      } as Record<QueryProp<NoInfer<Name>>, QueryMethod<NoInfer<TData>, NoInfer<TError>>>;
    }),
  );
};

Crafting a Store with Integrated Queries

Check out how to structure the store:

export const PostStore = signalStore(
  withState({ postId: 1}),
  withTanstackQuery('postId', (store) => {
    const apiService = inject(ApiPostService);
    
    return () => {
      const postId = store.postId();
      
      return {
        enabled: !!postId,
        queryKey: ['post', { id: postId }],
        queryFn: () => 
          lastValueFrom(apiService.getPost$(postId)).catch((error) => {
            console.error(error);
            return null;
          }),
      };
    };
  }),
);

Seamless Component Integration

It's really simple:

@Component({
  standalone: true,
  selector: 'app-example',
  template: `
    <pre>
      Loading: {{ store.postIdTanstackQuery.isLoading() }}
      Fetching: {{ store.postIdTanstackQuery.isFetching() }}
      Data:
      {{ store.postIdTanstackQuery.data() | json }}
    </pre>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [JsonPipe],
})
export class ExampleComponent {
  public readonly store = inject(PostStore);
}

Real-World Benefits

  1. Simplified State Logic: Less code, more functionality
  2. Optimized Performance: Built-in caching and background updates
  3. Easier Debugging: Clear, predictable state transitions
  4. Faster Development: Focus on logic, not configuration

Potential Pitfalls to Watch

  • Always use inject() for service dependencies
  • Leverage ChangeDetectionStrategy.OnPush religiously
  • Handle error states gracefully
  • Master query key strategies for caching and invalidation

Useful Resources

Wrapping Up

As Angular continues to evolve, solutions like NgRx Signal Store and Tanstack Query highlight the potential of modern web development. By embracing these tools, we not only solve today’s challenges but also prepare ourselves for a future of more efficient and scalable applications.

Happy Coding!