State Management
By Syncfusion®, Inc. · Last updated
Where to store app data: component state, shared state in services, BehaviorSubject and signal-based state, common patterns, and an overview of NgRx store, actions, reducers and selectors.
State Management Fundamentals
State is the data that your app stores at a moment in time. Examples: the items in the cart, the signed-in user, the selected tab and the text in a search box.
State management means deciding where each piece of state is stored and how it is changed. Good state management keeps the screen correct: every component shows the same, current data.
Real-World Example
In an online store, the header badge, the product page and the checkout page all need the cart. The cart is stored in one CartStore service, and every component reads it from there.
Components change the cart only with the service's methods, such as add(). So the badge and the checkout can never show different numbers.
CartStore serviceitems, add(), totalHeader badgereads countProduct pagecalls add()Checkoutreads totalOne place stores the cart. Every component reads it from there.
Shared state lives in one service.
There are two kinds of state:
| Kind | Who uses it | Example | Where to store it |
|---|---|---|---|
| Component state | One component | Is this panel open? | In the component |
| Shared state | Many components | Cart items, signed-in user | In a service |
Component State
Component state is data that only one component needs. Store it in a signal in that component.
Each copy of the component has its own state. In the next example, each FAQ item opens and closes on its own.
Component state: each item has its own open flag
import { Component, Input, signal } from '@angular/core';
@Component({
selector: 'app-faq-item',
template: `
<p>
<button (click)="open.update(v => !v)">{{ open() ? '−' : '+' }}</button>
{{ question }}
</p>
@if (open()) {
<p>{{ answer }}</p>
}
`,
})
export class FaqItem {
@Input() question = '';
@Input() answer = '';
open = signal(false);
}
@Component({
selector: 'app-root',
imports: [FaqItem],
template: `
<app-faq-item question="How long is delivery?" answer="3 to 5 working days." />
<app-faq-item question="Can I return an item?" answer="Yes, within 30 days." />
`,
})
export class App {}
open is component state. No other component needs to know if this item is open.
Shared State
Shared state is data that many components need. If each component stores its own copy, the copies can show different values.
You learned two ways to share data between components:
@Input()and@Output(): good for a parent and its direct child. With many levels, you must pass the data through every level.- A service: any component can inject it, at any level. This is the usual way to store shared state.
The next example shows the problem with copies. Header and Sidebar each store their own cartCount. The Add button changes only the copy in Header.
The problem: two copies of shared data
import { Component, signal } from '@angular/core';
@Component({
selector: 'app-header',
template: `
<p>Header cart: {{ cartCount() }}
<button (click)="cartCount.update(n => n + 1)">Add</button></p>
`,
})
export class Header {
cartCount = signal(0);
}
@Component({
selector: 'app-sidebar',
template: `<p>Sidebar cart: {{ cartCount() }}</p>`,
})
export class Sidebar {
cartCount = signal(0);
}
@Component({
selector: 'app-root',
imports: [Header, Sidebar],
template: `<app-header /><app-sidebar />`,
})
export class App {}
Click Add. The header shows 1, but the sidebar still shows 0. The section "Signal-Based State" below fixes this with one shared service.
Keep only one copy of each piece of shared data. When the same data is stored in two places, the two copies soon show different values.
Service-Based State
In service-based state, a service stores the shared data, and components read and change it through the service. You did this with the cart service in the Services lesson.
A good state service has three parts:
- The state: the data, kept private, so only the service can change it.
- Read access: fields or methods that components read.
- Methods that change the state, such as
add()andclear().
The next two sections show two ways to store the state inside the service: a BehaviorSubject and a signal.
BehaviorSubject State
You learned BehaviorSubject in the RxJS lesson. It stores the current value and sends every new value to its subscribers. So it can store shared state.
- Keep the
BehaviorSubjectprivate. - Give components an Observable with
asObservable(). Components can read it, but cannot callnext(). - In the template, display it with the
asyncpipe.
State in a BehaviorSubject
import { Component, Injectable, inject } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import { BehaviorSubject } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class UserStore {
private readonly user$ = new BehaviorSubject<string>('Guest');
readonly user = this.user$.asObservable();
login(name: string) {
this.user$.next(name);
}
logout() {
this.user$.next('Guest');
}
}
@Component({
selector: 'app-header',
imports: [AsyncPipe],
template: `<p>Header: Hello, {{ users.user | async }}</p>`,
})
export class Header {
users = inject(UserStore);
}
@Component({
selector: 'app-root',
imports: [AsyncPipe, Header],
template: `
<app-header />
<button (click)="users.login('Asha')">Log in as Asha</button>
<button (click)="users.logout()">Log out</button>
<p>Profile page: {{ users.user | async }}</p>
`,
})
export class App {
users = inject(UserStore);
}
Click Log in as Asha. The header and the profile page both show the new name, because both read the same BehaviorSubject.
Signal-Based State
You can store the same state in signals. This is the recommended way in new Angular code, because the template reads a signal directly, without the async pipe.
- Keep the writable signal private.
- Give components a read-only signal with
asReadonly(). - Use
computed()for values that come from the state, such as a count or a total.
State in signals
import { Component, Injectable, computed, inject, signal } from '@angular/core';
export interface CartItem {
name: string;
price: number;
}
@Injectable({ providedIn: 'root' })
export class CartStore {
private readonly _items = signal<CartItem[]>([]);
readonly items = this._items.asReadonly();
readonly count = computed(() => this._items().length);
readonly total = computed(() => this._items().reduce((sum, i) => sum + i.price, 0));
add(item: CartItem) {
this._items.update(list => [...list, item]);
}
clear() {
this._items.set([]);
}
}
@Component({
selector: 'app-cart-badge',
template: `<p>🛒 {{ cart.count() }} items</p>`,
})
export class CartBadge {
cart = inject(CartStore);
}
@Component({
selector: 'app-checkout',
template: `<p>Checkout total: {{ cart.total() }}</p>`,
})
export class Checkout {
cart = inject(CartStore);
}
@Component({
selector: 'app-root',
imports: [CartBadge, Checkout],
template: `
<app-cart-badge />
<button (click)="cart.add({ name: 'Mouse', price: 15 })">Add mouse ($15)</button>
<button (click)="cart.add({ name: 'Keyboard', price: 40 })">Add keyboard ($40)</button>
<button (click)="cart.clear()">Clear</button>
<app-checkout />
`,
})
export class App {
cart = inject(CartStore);
}
The badge and the checkout read the same CartStore. Each click changes the private _items signal, and both count and total recalculate.
| Point | BehaviorSubject state | Signal state |
|---|---|---|
| Read in the template | {{ store.user | async }} | {{ store.user() }} |
| Read in the class | subscribe() | store.user() |
| Values from the state | RxJS operators such as map() | computed() |
| Best for | Code that already uses RxJS | New code |
State Management Patterns
A pattern is a common, tested way to organize code. These are the usual patterns for state in Angular apps:
- Local state: a signal in the component, for data only that component needs.
- Store service: one service per feature, for example
CartStoreandUserStore, with private state, read-only access and methods. Most apps need only this pattern. - One-way data flow: components never change state directly. They call a method, the store changes the state, and the screen shows the new state.
- A state library such as NgRx: for large apps with many developers and complex rules. It is explained in the next section.
Start with local state and store services. Choose a library such as NgRx only when many features share state and the team needs strict rules for every change.
NgRx Overview
NgRx is a popular library for state management in large Angular apps. It is not part of Angular, so you install it separately:
ng add @ngrx/store
NgRx uses the one-way data flow pattern with strict rules. It has four main parts.
Componentdispatch(action)ActionaddItemReducerreturns new stateStoreholds the stateSelectorreads a partThe selector gives the new data back to the component, and the screen updates.
NgRx data flow. Data always moves in one direction.
NgRx Store
The store is one object that holds the state of the whole app. Components cannot change it directly. They read it with selectors and request changes with actions.
Actions
An action is an object that describes what happened, for example "an item was added to the cart". You create action types with createAction():
import { createAction, props } from '@ngrx/store';
export const addItem = createAction('[Cart] Add Item', props<{ name: string }>());
export const clearCart = createAction('[Cart] Clear');
The text '[Cart] Add Item' is the name of the action. props describes the extra data that the action carries.
Reducers
A reducer is a function that gets the current state and an action, and returns the new state. It never changes the old state; it creates a new one, the same as update() with signals.
import { createReducer, on } from '@ngrx/store';
export interface CartState {
items: string[];
}
const initialState: CartState = { items: [] };
export const cartReducer = createReducer(
initialState,
on(addItem, (state, action) => ({ items: [...state.items, action.name] })),
on(clearCart, () => ({ items: [] })),
);
Selectors
A selector is a function that reads one part of the state, for example the number of items. It works like a computed signal: it recalculates only when its part of the state changes.
import { createFeatureSelector, createSelector } from '@ngrx/store';
export const selectCart = createFeatureSelector<CartState>('cart');
export const selectCount = createSelector(selectCart, cart => cart.items.length);
Basic NgRx Data Flow
The parts work together in one direction:
- The user clicks a button, and the component dispatches (sends) an action:
store.dispatch(addItem({ name: 'Pen' })). - The reducer gets the action and returns the new state.
- The store saves the new state.
- A selector gives the new value to the component:
store.selectSignal(selectCount)returns it as a signal. - The screen shows the new value.
The practice blocks do not include NgRx. But you can see the same flow with only the Angular you already know: one signal holds the state, a reducer function creates the new state, and computed signals act as selectors.
The NgRx data flow in plain Angular
import { Component, Injectable, computed, inject, signal } from '@angular/core';
// Actions: objects that describe what happened
export type CartAction =
| { type: 'add', name: string }
| { type: 'clear' };
export interface CartState {
items: string[];
}
// Reducer: current state + action → new state
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case 'add':
return { items: [...state.items, action.name] };
case 'clear':
return { items: [] };
}
}
@Injectable({ providedIn: 'root' })
export class Store {
// Store: holds the state
private readonly state = signal<CartState>({ items: [] });
// Selectors: read one part of the state
readonly items = computed(() => this.state().items);
readonly count = computed(() => this.state().items.length);
dispatch(action: CartAction) {
this.state.update(current => cartReducer(current, action));
}
}
@Component({
selector: 'app-root',
template: `
<button (click)="store.dispatch({ type: 'add', name: 'Pen' })">Add pen</button>
<button (click)="store.dispatch({ type: 'clear' })">Clear</button>
<p>Count: {{ store.count() }}</p>
<p>Items: {{ store.items().join(', ') }}</p>
`,
})
export class App {
store = inject(Store);
}
The component only sends actions and reads selectors. It never changes the state itself. Real NgRx works the same way, with more tools for large apps.
export type CartAction = A | B is a TypeScript type alias that lists the allowed action objects. The | means "or": an action is either an add object or a clear object.
Common Mistakes
| Mistake | What you see | Fix |
|---|---|---|
| Each component stores its own copy of shared data | Different screens show different values | Store shared data in one service |
Public writable signal or BehaviorSubject in a store | Any component can change the state, and bugs are hard to find | Keep it private; give asReadonly() or asObservable() |
| Putting component-only data in a shared service | The service grows, and other screens are affected | Keep it as component state |
Changing state objects directly, for example state.items.push(x) | Computed signals and selectors keep old values | Return a new object |
| Adding NgRx to a small app | Many extra files for simple changes | Use store services; add NgRx when the app needs it |
Check Yourself
Exercises
What to Remember
- Store data that one component needs in that component. Store shared data in one service, keep it private, and change it only with the service's methods.
- A service can hold state in a
BehaviorSubject(read with theasyncpipe) or in signals (read with()). Signals are the simpler choice for new code. - NgRx uses one-way data flow: components dispatch actions, reducers return new state, the store holds it, and selectors give it back to components.
Next: Client-Side Rendering, how Angular creates the page in the browser.