Skip to content

RFC: Introduce withServerState for Granular Request Status Management #4969

@LcsGa

Description

@LcsGa

Which @ngrx/* package(s) are relevant/related to the feature request?

signals

Information

Summary / TL;DR

This proposal introduces a new withServerState feature for ngrx/signals. It aims to provide a granular, flexible, and intuitive way to manage the status of asynchronous operations (e.g., loading, creating, error) for individual slices of state within a SignalStore, rather than relying on a single, top-level request status.

The desired API would feel similar to modern data-fetching patterns, allowing developers to write:

// Access the value and status signals directly from the state property
store.product.value();    // T | null
store.product.loading();  // boolean
store.product.error();    // Error | undefined

Problem Statement

I've been using SignalStore for several months and have found it incredibly powerful. For managing the status of async requests, I initially implemented a withRequestStatus feature based on the official example. However, I quickly realized its limitations in real-world applications.

When a store manages multiple entities that can be fetched, created, or updated independently, a single, flat requestStatus property ({ loading: boolean, error: Error | null }) becomes ambiguous and hard to manage. It's impossible to know which operation is loading or has failed.

The current approach forces developers to either create multiple status slices (productsStatus, xxxStatus, etc.) or manage boolean flags manually, both of which add boilerplate and reduce clarity. There is a clear need for a built-in mechanism to associate a request's lifecycle with the specific piece of state it affects.

Proposed Solution

My proposal is to introduce a new feature, withServerState, built upon existing primitives like withProps and signalState. It treats each targeted state property as a "server-driven" entity, encapsulating both its value and its request status.

1. The ServerState Wrapper

First, we define a standard shape for this state, which includes the value and distinct boolean flags for common operations. A serverState() factory function creates this as a nested SignalState.

import { signalState, SignalState } from '@ngrx/signals';

// The shape for a state slice that syncs with a server.
export type ServerState<T> = {
  value: T;
  loading: boolean;
  creating: boolean;
  updating: boolean;
  deleting: boolean;
  error: Error | undefined;
};

// Factory to create a new ServerState signal.
export function serverState<T>(value: T): SignalState<ServerState<T>> {
  return signalState<ServerState<T>>({
    value,
    loading: false,
    creating: false,
    updating: false,
    deleting: false,
    error: undefined,
  });
}

2. The withServerState Feature

This is the core of the proposal. It's a signalStoreFeature that takes an initial state object and wraps each of its properties in a serverState.

import { signalStoreFeature, withProps } from '@ngrx/signals';

export function withServerState<State extends object>(initialState: State) {
  return signalStoreFeature(
    withProps(
      () =>
        Object.fromEntries(
          Object.entries(initialState).map(([key, value]) => [key, serverState(value)]),
        ) as { [K in keyof State]: SignalState<ServerState<State[K]>> },
    ),
  );
}

3. State Patching Utilities

To make state updates clean and predictable, a set of helper functions is provided. These return partial ServerState objects, perfect for use with patchState.

// Sets the 'creating' state
export function setCreating<T>(): Partial<ServerState<T>> {
  return { creating: true, loading: false, updating: false, deleting: false, error: undefined };
}

// Sets the 'loading' state
export function setLoading<T>(): Partial<ServerState<T>> {
  return { creating: false, loading: true, updating: false, deleting: false, error: undefined };
}

// Sets the 'updating' state
export function setUpdating<T>(): Partial<ServerState<T>> {
  return { creating: false, loading: false, updating: true, deleting: false, error: undefined };
}

// Sets the 'deleting' state
export function setDeleting<T>(): Partial<ServerState<T>> {
  return { creating: false, loading: false, updating: false, deleting: true, error: undefined };
}

// Sets the 'error' state, normalizing the error object
export function setError<T>(error: unknown): Partial<ServerState<T>> {
  return {
    creating: false,
    loading: false,
    updating: false,
    deleting: false,
    error: error instanceof Error ? error : new Error('An error occurred', { cause: error }),
  };
}

// Resets the status and sets the final value on success
export function setComplete<T>(value: T): Partial<ServerState<T>> {
  return {
    value,
    creating: false,
    loading: false,
    updating: false,
    deleting: false,
    error: undefined,
  };
}

Usage Example

Here is how this feature would look in practice inside a ProductStore.

import { withState, withMethods, patchState } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { switchMap } from 'rxjs';
import { tapResponse } from '@ngrx/operators';

export const ProductStore = signalStore(
  // Regular state
  withState({ selectedId: 1 }),

  // State properties managed with their server status
  withServerState({ product: null as Product | null }),

  withMethods((store, gateway = inject(ProductGateway)) => ({
    select: (id: number) => patchState(store, { selectedId: id }),

    load: rxMethod<number>(
      switchMap((id) => {
        // 1. Set the specific state slice to loading
        patchState(store.product, setLoading());

        return gateway.getOneById(id).pipe(
          tapResponse({
            // 2. On success, update the value and reset status
            next: (product) => patchState(store.product, setComplete(product)),
            // 3. On error, set the error state
            error: (err) => patchState(store.product, setError(err)),
          }),
        );
      }),
    ),
  })),

  withHooks({
    onInit: ({ load, selectedId }) => load(selectedId),
  }),
);

Proof of Concept (POC)

To further illustrate this proposal, I have created a proof-of-concept repository that demonstrates this feature in a concrete example application. I invite everyone to explore the code and see the implementation in a real-world scenario.

You can find the repository here: https://github.com/LcsGa/server-state-feature

Feedback on this implementation is also very welcome.

Bonus: Ergonomic RxJS Operators

To further improve the developer experience and reduce boilerplate within rxMethod, we can introduce a set of custom RxJS tap operators that automatically patch the ServerState.

// The core operator (implementation omitted for brevity, see original post)
// ... tapServerState ...

// Public-facing operators
export function tapServerStateLoading<T>(state: SignalState<ServerState<T>>, /*...*/) { /*...*/ }
export function tapServerStateCreating<T>(state: SignalState<ServerState<T>>, /*...*/) { /*...*/ }
export function tapServerStateUpdating<T>(state: SignalState<ServerState<T>>, /*...*/) { /*...*/ }
export function tapServerStateDeleting<T>(state: SignalState<ServerState<T>>, /*...*/) { /*...*/ }

With these operators, the load method becomes incredibly concise:

// Updated usage example with the operators
export const ProductStore = signalStore(
  withState({ selectedId: 1 }),
  withServerState({ product: null as Product | null }),
  withMethods((store, gateway = inject(ProductGateway)) => ({
    select: (id: number) => patchState(store, { selectedId: id }),

    load: rxMethod<number>(
      switchMap((id) => gateway.getOneById(id).pipe(
        // Automatically patches loading, complete, and error states
        tapServerStateLoading(store.product),
      )),
    ),
  })),
  withHooks({ onInit: ({ load, selectedId }) => load(selectedId) })
);

Open Questions & Points for Discussion

This proposal is a starting point, and I'd like to open the floor to a few specific questions to guide the discussion:

  • Integration with withEntities: How could this concept be extended to work seamlessly with withEntities? Should the status be per-entity (e.g., tracking the updating state of a specific entity), for the collection as a whole (e.g., when the entire collection is being loaded) or both of them? This could potentially lead to a withServerEntities feature.
  • Naming and Terminology : Are the names withServerState, ServerState,... the most appropriate? Can you find any other names?

Motivation and Rationale

I have seen other community packages that add TanStack Query-like features to SignalStore. While powerful, they can introduce a high level of complexity and opinionation.

The solution proposed here is intentionally minimalist, flexible, and foundational.

  • Granularity: It solves the core problem of tracking status per state slice.
  • Simplicity : It's built entirely on existing NgRx primitives (withProps, signalState, patchState), making it easy to understand and adopt.
  • Flexibility: It doesn't dictate how you should fetch data. You can use it with rxMethod, fetch, or any other asynchronous pattern. The patching utilities provide full manual control when needed.
  • Excellent DX: The resulting API is clean, type-safe, and significantly reduces boilerplate, especially with the optional RxJS operators.

I believe this is a missing piece in the official ngrx/signals package that would solve a very common use case. By providing a basic but powerful primitive like withServerState, we can empower developers to build complex applications more effectively.

What are your thoughts on this? I'm open to any feedback.

Describe any alternatives/workarounds you're currently using

No response

I would be willing to submit a PR to fix this issue

  • Yes
  • No

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions