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