With signals, most applications no longer need a state-management library. A service with a few private signals, computed() selectors and methods with clear names covers almost every case. The important question isn’t “which library”, but where each piece of state lives. When a screen keeps reaching combinations that shouldn’t exist, the answer is a state machine.
The levels of state
Kind of state Example Where you keep it
────────────────────────────────────────────────────────────────────────────────
Local UI open dropdown, hover, tab signal() in the component
Form fields, validation, dirty Signal Forms (form())
Feature filters, selection, list @Injectable() store on the route/page
Server products, orders resource / httpResource (in the store)
URL search, page, shareable filter query params + input() (router)
Global current user, cart, theme, lang @Service() in rootKeep the state at the lowest level where it is needed. Lift it (component → feature → global) only when a new consumer requires it.
The basic pattern: a store on signals
@Injectable() // provided by the feature’s page / route
export class TodoStore {
// 1. STATE — private, writable
private readonly _todos = signal<Todo[]>([]);
private readonly _filter = signal<TodoFilter>('all');
// 2. SELECTORS — public, read-only
readonly todos = this._todos.asReadonly();
readonly filter = this._filter.asReadonly();
readonly visible = computed(() => {
const f = this._filter();
return this._todos().filter((t) => f === 'all' || (f === 'done' ? t.done : !t.done));
});
readonly remaining = computed(() => this._todos().filter((t) => !t.done).length);
// 3. ACTIONS — methods with domain names, immutable updates
add(title: string) {
this._todos.update((list) => [...list, { id: Date.now(), title, done: false }]);
}
toggle(id: number) {
this._todos.update((list) => list.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
}
setFilter(filter: TodoFilter) {
this._filter.set(filter);
}
}The filter is also in the URL (?filtru=…) — reload the page: both the list (localStorage) and the filter (URL) remain.
Server state
Data from the server is not “your state” — it is a cache of the server’s state. Keep it in httpResource/resource inside the store, and after writes reload or update locally.
// A store with server data: resource for reading, HttpClient for writing
@Service()
export class ProductsStore {
private readonly api = inject(ProductsApi);
readonly query = signal('');
readonly page = linkedSignal({ source: this.query, computation: () => 1 });
readonly products = httpResource<PageDto<Product>>(
() => ({ url: '/api/products', params: { q: this.query(), page: this.page() } }),
{ defaultValue: { items: [], total: 0 } },
);
readonly isEmpty = computed(() => !this.products.isLoading() && this.products.value().items.length === 0);
async remove(id: number) {
await firstValueFrom(this.api.delete(id));
this.products.reload();
}
}The URL is state too
// State that must survive a refresh / be sendable as a link → the URL
// with withComponentInputBinding(), query params go straight into inputs:
@Component({ /* routed at /products?q=laptop&page=2 */ })
export class ProductsPage {
readonly q = input<string>(''); // ?q=
readonly page = input(1, { transform: numberAttribute }); // ?page=
private readonly router = inject(Router);
search(q: string) {
this.router.navigate([], { queryParams: { q, page: null }, queryParamsHandling: 'merge' });
}
}Facade
When a page combines data from several stores, a facade gathers them into a single “view model”. The component stays thin, and the orchestration has a clear place.
// Facade: a single entry point for the page, hiding several sources
@Injectable()
export class CheckoutFacade {
private readonly cart = inject(CartStore);
private readonly user = inject(AuthStore);
private readonly shipping = inject(ShippingStore);
readonly vm = computed(() => ({
items: this.cart.items(),
total: this.cart.total(),
address: this.user.current()?.address,
shippingOptions: this.shipping.options.value(),
canPay: this.cart.items().length > 0 && !!this.user.current(),
}));
pay() { /* orchestration between stores */ }
}
// the component: @let vm = facade.vm(); — one object, easy to testAnd NgRx?
// When the team wants conventions + plugins: NgRx SignalStore (@ngrx/signals)
export const TodoStore = signalStore(
withState({ todos: [] as Todo[], filter: 'all' as TodoFilter }),
withComputed(({ todos, filter }) => ({
visible: computed(() => todos().filter(/* ... */)),
})),
withMethods((store) => ({
add(title: string) {
patchState(store, (s) => ({ todos: [...s.todos, { id: Date.now(), title, done: false }] }));
},
})),
);
// + withEntities, withHooks, devtools, rxMethod for asynchronous effects- Most applications, including large ones.
- Teams that prefer simple code without boilerplate.
- Feature state, tied to pages.
- SignalStore: you want imposed conventions, entities, hooks, reusable plugins.
- NgRx Store (Redux): you need an action log, time-travel, event sourcing, many teams on the same state.
- The application already uses NgRx — keep consistency.
Common pitfalls
- Everything global: page state in root → old filters appear when you come back, memory grows. Provide the store on the route/page.
- Public writable signals in a store → anyone can change the state from anywhere. Expose
asReadonly(). - Duplicated state (the same list in two stores) → desynchronisation. One source, the rest derived.
- Stores that inject each other circularly → a sign that a concept must be moved (or that you need a facade).