Not every Angular application uses a state management library. In many projects, a well-written service is entirely sufficient: in newer versions of Angular based on signals, and in older ones that don’t have signals yet (before Angular 16), on BehaviorSubject from RxJS. There are also applications in which state is managed by a library, most often NgRx. This article introduces the basic solutions, from the simplest to the more elaborate, and shows when it’s worth reaching for each of them.
State is simply data that changes and that the interface has to display. It is the single source of truth in our application: components don’t keep their own copies of this data, they only read it from the state and display whatever it currently contains.
Guiding example: a cart
Throughout the article we develop a single example: a cart in a small shop. We start with the simplest version, in which the cart is handled by just one component. In the following sections the shop gets new features: a product counter in the header, a discount code, a product list fetched from an API. Each of them makes the current solution inconvenient, and that’s when we move up a level. A cart item looks like this:
interface CartItem {
id: number;
name: string;
price: number;
qty: number;
}
Why does this matter? At first, a single service injected into several components is entirely sufficient. The problem appears when the application grows: more fields, methods and API handling end up in the same service, and different parts of the application change its data on their own. Such a service becomes unreadable, it’s hard to find bugs in it, and every person on the team extends it in their own way.
The natural consequence of an application growing is therefore the need for a store, that is, a place with a clearly defined structure: with a single state, an established way of reading it and a single permitted way of changing it.
The article is divided into levels, from the simplest and oldest solution to the most elaborate and modern one:
- State in a component
- A service with BehaviorSubject and a simple service as a store
- Classic NgRx Store
- NgRx SignalStore
The examples assume Angular 20 or newer, standalone components and the new template syntax (@for, @if).
Level 0: state in a component
If only one component needs the data, keep it in that component. There’s no reason to create a service for state that nobody else reads.
@Component({
selector: 'app-cart',
template: `
<p>Items in cart: {{ count() }}</p>
<button (click)="add(product)">Add</button>
`,
})
export class CartComponent {
readonly items = signal<CartItem[]>([]);
readonly count = computed(() => this.items().length);
add(item: CartItem) {
this.items.update(items => [...items, item]);
}
}
signal() stores a value, and computed() derives a value from it and recalculates it automatically when the source changes. In the template we read a signal by calling it like a function: count().
Transition point: a cart icon with the number of products is supposed to appear in the page header. Now the same data is needed by two components that aren’t directly related to each other. They are independent components, located in completely different places in the project’s file structure, e.g. layout/header/cart-badge and features/cart/cart. Passing the data through input() and output() across several levels of the tree quickly becomes tedious and is incorrect.
Level 1a: a service with BehaviorSubject
We move the shared state to a service, e.g. src/app/core/services/cart.service.ts, and both components (layout/header/cart-badge and features/cart/cart) inject the same instance of it. Before signals appeared in Angular, the standard for this was BehaviorSubject from RxJS. You’ll still come across it in many projects, so it’s worth understanding.
@Injectable({ providedIn: 'root' })
export class CartService {
private readonly _items$ = new BehaviorSubject<CartItem[]>([]);
readonly items$ = this.items$$.asObservable();
readonly total$ = this.items$.pipe(
map(items => items.reduce((sum, i) => sum + i.price * i.qty, 0))
);
add(item: CartItem) {
this.items$$.next([...this.items$$.value, item]);
}
}
The component reads the data through the async pipe. It automatically subscribes to the stream and ends the subscription when the component is destroyed, so we don’t have to do it manually:
@Component({
selector: 'app-cart-badge',
imports: [AsyncPipe],
template: `<span>{{ total$ | async }} zł</span>`,
})
export class CartBadgeComponent {
readonly total$ = inject(CartService).total$;
}
What’s worth remembering from this pattern:
- Why BehaviorSubject and not a plain Subject? Both are data streams that can be subscribed to. However, they differ in what a component receives at the moment of subscription. BehaviorSubject stores the last value and passes it on immediately. Importantly, BehaviorSubject requires an initial value (in our case an empty array []), so even if no change has happened yet, the component receives it right away. Subject doesn’t store the last value. After subscribing, you only receive values emitted from that moment on. That’s why Subject doesn’t work well as state: a component that appears on the screen after the last change won’t get the current data and, until the next emission, will have a gap in the displayed state. So to store state we choose BehaviorSubject.
- Encapsulation. The _items$ field is private, so no component can call next() on it and replace the data in the cart on its own. Externally we only expose items$, that is, the version created with asObservable(): you can subscribe to it and read data from it, but you can’t change anything through it. The only way to change the state is through the service’s methods, such as add(). Thanks to this, you always know where to look for the code that modifies the cart.
- With every change we create a new array. Calling push() adds a product to the same array that’s already sitting in the BehaviorSubject. The data in memory changes, but we haven’t called next(), so the BehaviorSubject doesn’t send a new value: components don’t find out about anything, and the view doesn’t refresh. That’s why in the add() method we build a new array with the old elements and the new product ([…this._items$value, item]) and pass it through next(). A new array is also a new object in memory, and that’s how Angular and RxJS operators most easily recognize that something has changed.
Typical pitfalls are a manual subscribe() in a component without unsubscribing later (a memory leak) and reaching for .value from outside the service, which bypasses the whole reactivity mechanism.
Level 1b: a simple service as a store
New requirement: the cart gets a discount code and information on whether an order is currently being saved. We could add three separate BehaviorSubject streams, but then inconsistency comes easily, for example clearing the items without clearing the discount.
Until now, our service stored only one thing: the list of products in the cart. In a real application, state quickly grows and consists of several pieces of information that depend on each other. For example, a discount code only makes sense when there are products in the cart: after the cart is cleared, the discount should disappear along with them, and the “Order” button should be disabled while the order is being saved. Instead of keeping each of these pieces of information (the product list, the discount code and the information, loading) in a separate variable, we gather them into one object and allow it to be changed in only one place. The service is still a regular Angular service, but it works like a small store: it has a single state, established ways of reading it and a single path for changing it.
interface CartState {
items: CartItem[];
discountCode: string | null;
saving: boolean;
}
const initialState: CartState = {
items: [],
discountCode: null,
saving: false,
};
@Injectable({ providedIn: 'root' })
export class CartStore {
private readonly _state$ = new BehaviorSubject<CartState>(initialState);
readonly items$ = this.select(s => s.items);
readonly discountCode$ = this.select(s => s.discountCode);
readonly saving$ = this.select(s => s.saving);
readonly total$ = this.items$.pipe(
map(items => items.reduce((sum, i) => sum + i.price * i.qty, 0))
);
private select<T>(selector: (state: CartState) => T): Observable<T> {
return this._state$.pipe(map(selector), distinctUntilChanged());
}
private patch(partial: Partial<CartState>) {
this.state$$.next({ ...this._state$.value, ...partial });
}
add(item: CartItem) {
this.patch({ items: [...this._state$.value.items, item] });
}
applyDiscount(code: string) {
this.patch({ discountCode: code });
}
reset() {
this._state$.next(initialState);
}
}
This code contains three elements that are at the heart of every state management library:
- Initial state (initialState): you always know what you’re starting from
- Selectors (items$, total$): components read only what they need, and derived values are calculated in one place.
- A single update function (patch): every change goes through the same place, so it’s easy to debug or log.
In older projects you’ll find this pattern under the name “service with a subject”, often with select() and setState() methods.
Transition point: the application grows and you notice that you’re writing the same code in more and more services. A saving flag, save error handling, a cache between screens. When you start copying the same pieces between services, it’s a good sign that it’s time for a reusable solution that will encapsulate this repetitive code in one place.
Level 2: classic NgRx Store
Classic NgRx (@ngrx/store) is based on the Redux pattern, popularized by the library of the same name in the React world. Its premise is simple: the entire application state lives in one place, and it can be changed only in one strictly defined way, described below. This approach gives the most predictability at the cost of the largest amount of code.
The most important difference: the component doesn’t call a method that changes the state. Instead, we call an action, that is, a description of what happened. The flow looks like this:
component → dispatch(action) → reducer (store) → selector → component
- An action is a plain object describing what happened, e.g. “product added to cart” along with that product. The component calls it with the dispatch() method.
- A reducer is a function that modifies and holds the current state of the application. It does nothing more: it doesn’t call the API
- A selector is a function that extracts only the needed piece of state from the store, e.g. the product list or the cart total. It corresponds to our items$ and total$.
Asynchronous operations, such as saving an order, are handled by separate effects: they listen for actions, call the API and send another action with the result.
In short, the same cart looks like this:
export const CartActions = createActionGroup({
source: 'Cart',
events: {
'Item Added': props<{ item: CartItem }>(),
'Cart Reset': emptyProps(),
},
});
export const cartReducer = createReducer(
initialState,
on(CartActions.itemAdded, (state, { item }) => ({
...state,
items: [...state.items, item],
})),
on(CartActions.cartReset, () => initialState)
);
// in a component / facade
this.store.dispatch(CartActions.itemAdded({ item }));
readonly items$ = this.store.select(selectCartItems);
For a small application, this is usually too much. However, if you join a large project, it’s quite likely that you’ll come across exactly this pattern.
A big advantage is debugging: NgRx Store works with Redux DevTools, a browser extension in which you can see every dispatched action and the state before and after it, and you can even roll the application back to an earlier state. All you need to do is add provideStoreDevtools() from the @ngrx/store-devtools package.
Level 3: NgRx SignalStore
SignalStore is a simplified version of NgRx from the same creators, based on signals (Angular 16+). It does the same as the classic Store from level 2, but without actions and scattered files: the state, the selectors (withComputed()) and the update logic, which in level 2 was split between reducers and effects (withMethods()), are gathered in one place. Thanks to this it closely resembles our CartStore from level 1b, except that instead of writing everything by hand, you assemble it from ready-made library functions (withState(), withComputed(), withMethods()).
The same cart in SignalStore:
import { computed } from '@angular/core';
import {
signalStore, withState, withComputed, withMethods, patchState,
} from '@ngrx/signals';
export const CartStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withComputed(({ items }) => ({
total: computed(() =>
items().reduce((sum, i) => sum + i.price * i.qty, 0)
),
})),
withMethods(store => ({
add(item: CartItem) {
patchState(store, { items: [...store.items(), item] });
},
applyDiscount(code: string) {
patchState(store, { discountCode: code });
},
reset() {
patchState(store, initialState);
},
}))
);
The table contains comparisons of each approach. The ideas are the same; only the names and the place where you write them change:
| Purpose | Simple service | Classic NgRx Store | SignalStore |
|---|---|---|---|
| Storing state | initialState + BehaviorSubject | initialState in createReducer() | withState(initialState) |
| Reading slices of state and derived values | select() and pipe(map(…)) | selectors (createSelector()) | withComputed() (state fields available automatically) |
| Changing state | private patch() method | reducer (on(…)) | patchState() inside methods |
| Triggering a change from a component | public methods, e.g. add() | action sent via dispatch() | methods from withMethods() |
| Asynchronous operations, e.g. API | service methods with HttpClient | effects (createEffect()) | methods in withMethods(), e.g. with rxMethod() |
In a component you use it the same way as a service: readonly cart = inject(CartStore) and in the template cart.total().
What you gain compared to a hand-written service:
- A consistent structure. Every store in the project looks the same, so a new person on the team immediately knows where to look for the state, selectors and methods.
- Ready-made extensions. For example, withEntities() from @ngrx/signals/entities for collections with adding, removing and updating by id, or rxMethod() for RxJS-based methods.
- Debugging tools. The community provides ready-made extensions, e.g. withDevtools() from the @angular-architects/ngrx-toolkit package, which shows state changes in Redux DevTools, similarly to classic NgRx from level 2.
- Your own reusable extensions. Repetitive logic, e.g. the saving mentioned earlier and error handling, you encapsulate in a single signalStoreFeature() and add to any store.
Alternatives
NgRx isn’t the only state management library in Angular. There are others too, and these are some of the more popular ones: NGXS is an approach similar to NgRx Store, but with less code, based on classes and decorators; Elf is a lightweight library based on RxJS; TanStack Query for Angular solves only the problem of server state (caching, refreshing, mutations) and is sometimes a better choice than a Store if your main pain point is data from an API.
Summary
State management in Angular is a ladder, not a choice of a single “right” library. Many applications stop at a simple service as a store.
The most important thing is what level 1b taught us: initial state, selectors and a single update function. If you write services this way, switching to SignalStore will take you less rather than more time. The library then changes nothing in your thinking; it only organizes what you’re already doing.
If you’re starting a new project, it’s worth considering NgRx SignalStore from the very beginning. Today it adds almost zero code overhead compared to a hand-written service, so it will work well even in a small application, and when the project grows, you won’t have to rewrite anything.
If, on the other hand, you’re joining an existing project, treat this article as a map. First, determine which level your team’s solution is at: a service with BehaviorSubject, classic NgRx with actions and reducers, or SignalStore. Then find in the code the three things that ran through the whole text: where the state is kept, where components read it from, and how it’s changed. Once you find them, the rest of the solution will stop being a mystery, regardless of which library the project uses.