-
-
Notifications
You must be signed in to change notification settings - Fork 2k
Description
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 | undefinedProblem 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 withwithEntities? 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 awithServerEntitiesfeature. - 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