Angular 20 · NgRx 20 · Signals
A counter is the smallest app that still uses every part of NgRx. This post walks through the real code behind one: actions, a reducer, selectors, a facade, and a zoneless component that reads the count as a signal.
(click)→ dispatch→ reduce→ store→ select→ render
- Why a store for a counter?
- The architecture in one picture
- Where each file lives
- Step 1 · Install NgRx
- Step 2 · Actions
- Step 3 · Reducer
- Step 4 · Selectors
- Step 5 · Register the Store
- Step 6 · Facade
- Step 7 · Component
- Follow one click
- Try it yourself
- Debug with Redux DevTools
- Mistakes to avoid
- Where to go next
Why use a store for a counter?
You could keep the count in a signal inside the component and stop there. That works until a second component needs the same number, or a bug makes you ask why the number changed. NgRx handles both. It keeps app data in one place, called the Store, and only lets that data change through one path that you can log, inspect and replay.
A bank account is a useful picture. You never edit your balance by hand. You hand in a deposit slip, the bank applies its rules, and a new balance goes into the ledger. When you want to know the balance, you ask for it. Each piece of NgRx has a job like that:
| Piece | File | Its job | In the bank |
|---|---|---|---|
| Action | counter.actions.ts | Says what happened, like “Increment was clicked”. | Deposit slip |
| Reducer | counter.reducer.ts | Takes the current state and an action, returns the next state. | The bank’s rules |
| State | inside the Store | The current data, { count: 0 }. One copy for the whole app. | The ledger |
| Selector | counter.selector.ts | Reads one piece of state and caches the result. | Balance inquiry |
| Facade | counter.facade.ts | A small service that hides NgRx behind plain methods. | The teller window |
| Component | app.ts, app.html | Shows the count and calls the facade on click. | You |
The architecture in one picture
This is how the files in the project connect. Writes travel down the left side. Reads come back up the right side.
Two rules keep this predictable. Data moves in one direction only. And only the reducer creates new state. Everything else either asks for a change by dispatching an action, or reads the result through a selector.
Where each file lives
All NgRx code for the counter sits in one folder, with one file per job. The component and config files outside it only needed small edits.
src/app/ ├── app.config.ts registers the Store ├── app.ts component, uses the facade ├── app.html template, shows count() ├── app.scss button styles └── counterStore/ ├── counter.actions.ts what can happen ├── counter.reducer.ts how state changes ├── counter.selector.ts how to read state └── counter.facade.ts simple API for components
Step 1 · Packages
Install NgRx
NgRx releases follow Angular’s major versions, so an Angular 20 app uses NgRx 20. This project adds three packages:
npm install @ngrx/store@20 @ngrx/effects@20 @ngrx/store-devtools@20
@ngrx/storegives you the Store, actions, reducers and selectors.@ngrx/store-devtoolsconnects the Store to the Redux DevTools browser extension.@ngrx/effectsruns side effects such as HTTP calls. The counter doesn’t need one yet, but it is registered so it is ready.
Afterwards, package.json has three new lines:
"@angular/router": "^20.3.0",
+ "@ngrx/effects": "^20.1.0",
+ "@ngrx/store": "^20.1.0",
+ "@ngrx/store-devtools": "^20.1.0",
"rxjs": "~7.8.0",
This commit also adds "cli": { "analytics": false } to angular.json. That only turns off Angular CLI usage analytics, which is what ng analytics disable writes. It isn’t related to NgRx.
Step 2 · Actions
Describe what can happen
An action is a plain object that says something happened. It always has a type string and can carry extra data. createActionGroup creates every action for one feature in a single call.
import { createActionGroup, emptyProps } from '@ngrx/store';
export const counterActions = createActionGroup({
source: 'Counter',
events: {
Increment: emptyProps(),
Decrement: emptyProps(),
Reset: emptyProps(),
},
});
Each event name turns into a function you can call. NgRx builds the type string from the source and the event name:
Increment: emptyProps()counterActions.increment(){ type: '[Counter] Increment' }emptyProps() means these actions carry no extra data. The [Counter] prefix tells you where an action came from when you read the action log later.
Step 3 · Reducer
Decide how the state changes
The reducer is the only code that creates new state. It is a pure function: give it the current state and an action, and it returns the next state. The same input always gives the same output.
import { createReducer, on } from '@ngrx/store';
import { counterActions } from './counter.actions';
export interface countState {
count: number;
}
export const initialState: countState = {
count: 0,
};
export const counterFeatureKey = 'counter';
export const counterReducer = createReducer(
initialState,
on(counterActions.increment, (state) => ({
...state,
count: state.count + 1,
})),
on(counterActions.decrement, (state) => ({
...state,
count: state.count - 1,
})),
on(counterActions.reset, (state) => ({
...state,
count: 0,
})),
);
countStatedescribes the shape of this slice of state.initialStateis the value before any action runs, so the count starts at 0.counterFeatureKeyis the name of this slice in the global state object, so it lives atstate.counter.- Each
on()pairs an action with a function that returns the next state. ...statecopies any other fields. The counter only has one today, but this keeps the reducer correct when you add more.
The most common beginner mistake is changing the state object directly. Compare the two versions:
✗ Changes the old object throws in dev mode
on(counterActions.increment, (state) => {
state.count++;
return state;
}),
✓ Returns a new object what this project does
on(counterActions.increment, (state) => ({
...state,
count: state.count + 1,
})),
NgRx notices changes by comparing object references. If you change the old object and return it, the reference stays the same, so selectors assume nothing happened and the screen doesn’t update. In development mode NgRx also freezes the state, so the left version throws an error on the first click.
Step 4 · Selectors
Read exactly what you need
The Store keeps one object for the whole app. After the counter registers, it looks like this:
{
"counter": {
"count": 0
}
}
Selectors are functions that read pieces of that object.
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { counterFeatureKey, countState } from './counter.reducer';
export const selectCounterState = createFeatureSelector<countState>(counterFeatureKey);
export const selectCount = createSelector(selectCounterState, (state: countState) => state.count);
createFeatureSelectorpicks thecounterslice out of the global state.createSelectortakes that slice and returns just the number.
Selectors are memoized. If the counter slice is the same object as last time, selectCount returns its cached result without running again. That keeps reads cheap, even when many components use them.
Step 5 · Configuration
Register the Store in app.config.ts
This project is a standalone Angular app with no AppModule, so NgRx is registered with provider functions in app.config.ts.
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection, isDevMode } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { provideState, provideStore } from '@ngrx/store';
import { provideStoreDevtools } from '@ngrx/store-devtools';
import { provideEffects } from '@ngrx/effects';
import { counterFeatureKey, counterReducer } from './counterStore/counter.reducer';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideZonelessChangeDetection(),
provideRouter(routes),
provideStore(),
provideState(counterFeatureKey, counterReducer),
provideStoreDevtools({ maxAge: 25, logOnly: !isDevMode() }),
provideEffects(),
],
};
| Provider | What it does |
|---|---|
provideStore() | Creates the global Store. It starts empty, and features add themselves. |
provideState(counterFeatureKey, counterReducer) | Adds the counter slice at state.counter. |
provideStoreDevtools({ maxAge: 25, logOnly: !isDevMode() }) | Connects to Redux DevTools and keeps the last 25 actions. Watch-only in production builds. |
provideEffects() | Starts the effects system. There are no effects yet. |
provideZonelessChangeDetection() | Already in the project. Runs Angular without zone.js, so signals tell Angular when to re-render. |
The feature key is used in two places: provideState here and createFeatureSelector in the selector file. Both read the counterFeatureKey constant, so a typo can’t make them disagree.
Step 6 · Facade
Hide the Store behind a facade
A facade is a normal Angular service that sits between components and the Store. Components call its methods and read its signals. They never import actions, selectors or the Store itself.
import { Injectable, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import { counterActions } from './counter.actions';
import { selectCount } from './counter.selector';
@Injectable({ providedIn: 'root' })
export class CounterFacade {
private readonly store = inject(Store);
readonly count = this.store.selectSignal(selectCount);
increment(): void {
this.store.dispatch(counterActions.increment());
}
decrement(): void {
this.store.dispatch(counterActions.decrement());
}
reset(): void {
this.store.dispatch(counterActions.reset());
}
}
selectSignal turns a selector into an Angular signal. When the counter state changes, the signal gets the new value, and every template that reads it updates. providedIn: 'root' gives the whole app one shared instance.
Here is the difference from the component’s point of view:
Without a facade the component knows NgRx
export class App {
private store = inject(Store);
count = this.store.selectSignal(selectCount);
increment(): void {
this.store.dispatch(counterActions.increment());
}
}
With a facade this project
export class App {
private counterFacade = inject(CounterFacade);
count = this.counterFacade.count;
increment(): void {
this.counterFacade.increment();
}
}
- The component stays small and only knows about counting.
- Tests can swap in a fake facade without setting up a Store.
- If you move to NgRx SignalStore later, only the facade has to change.
Step 7 · Component
Show the count on screen
The component injects the facade and hands its signal and methods to the template.
import { Component, inject, signal } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { CounterFacade } from './counterStore/counter.facade';
@Component({
selector: 'app-root',
imports: [RouterOutlet],
templateUrl: './app.html',
styleUrls: ['./app.scss'],
})
export class App {
protected readonly title = signal('angular20_Learning');
private readonly counterFacade = inject(CounterFacade);
count = this.counterFacade.count;
increment(): void {
this.counterFacade.increment();
}
decrement(): void {
this.counterFacade.decrement();
}
reset(): void {
this.counterFacade.reset();
}
}
<h1>Welcome to Angular 20 Learning</h1>
<p>COUNT: {{ count()}}</p>
<div class="counter">
<button (click)="decrement()">Decrement</button>
<button (click)="increment()">Increment</button>
<button (click)="reset()">Reset</button>
</div>
<router-outlet />
count is the same signal the facade exposes, so the template calls it like a function: {{ count() }}. Each button calls a method that forwards to the facade.
This app uses provideZonelessChangeDetection(), so there is no zone.js watching every click and timer. Angular re-renders a component when a signal its template reads has changed. A signal from selectSignal fits that model directly. You don’t need the async pipe, a manual subscribe, or ChangeDetectorRef.
app.scss (button styles only)
.counter {
display: flex;
width: 300px;
justify-content: space-between;
font-size: 1rem;
font-weight: bold;
font-family: system-ui, sans-serif;
}
.counter button {
background-color: #f0f0f0;
border: none;
padding: 10px;
cursor: pointer;
font-size: 1rem;
border-radius: 5px;
}
.counter button:active {
background-color: #e0e0e0;
}
Follow one click through the app
Now put it all together. This is everything that happens when you click Increment while the count is 0.
- 1
The button’s
(click)binding callsincrement()on theAppcomponent. - 2
Appforwards the call tocounterFacade.increment(). - 3
The facade dispatches
counterActions.increment(), which is just{ type: '[Counter] Increment' }. - 4
The Store hands the current state,
{ count: 0 }, and the action tocounterReducer. - 5
The matching
on()returns a new object,{ count: 1 }. The Store saves it asstate.counter. - 6
The state changed, so the Store lets its selectors know.
- 7
selectCountreads the new slice, and thecountsignal now holds 1. - 8
The template reads
count(), gets 1, and Angular re-renders that part of the page.
The Store updates the state synchronously during dispatch. Angular then schedules a re-render, because a signal the template reads has changed.
Try it yourself
This panel runs the same reducer logic in plain JavaScript. Click the buttons and watch the path light up in order, the action log grow, and the state change. Click any log entry to see the state right after it, the way Redux DevTools shows it.
Welcome to Angular 20 Learning
COUNT:
Last action: none yet
{
"counter": {
"count": 0
}
}
Diff
+ counter: { count: 0 }
The log drops its oldest entry after 25 actions, like maxAge: 25 in app.config.ts. The two @ngrx entries at the top are NgRx’s own startup actions. update-reducers is the moment provideState adds the counter slice, which is why its diff shows counter appearing.
Debug with Redux DevTools
- Install the Redux DevTools extension for Chrome, Edge or Firefox.
- Run
ng serveand openhttp://localhost:4200. - Open the browser’s developer tools and choose the Redux tab.
The left side lists every action in order: NgRx’s startup actions first, then one [Counter] entry per click. Select an action and use the tabs on the right:
| Tab | What it shows |
|---|---|
| Action | The action object, for example { type: '[Counter] Increment' }. |
| State | The whole state tree after that action. |
| Diff | Only what changed, for example count: 1 → 2. |
The slider at the bottom moves back and forth through history. This is called time-travel debugging, and it works because every state is a new object that never changes afterwards.
Two options in app.config.ts control the connection. maxAge: 25 keeps the last 25 actions, so memory doesn’t grow forever. logOnly: !isDevMode() puts the extension in watch-only mode in production builds.
Mistakes to avoid
-
Don’tChange state in place, like
state.count++.DoReturn a new object with the spread operator. -
Don’tPut side effects in a reducer: HTTP calls,
localStorage,Date.now(),Math.random().DoMove them to an effect.provideEffects()is already registered for this. -
Don’tInject the Store into every component.DoGo through the facade, so components stay simple and easy to test.
-
Don’tType the feature key as a string in two files.DoShare one constant, like
counterFeatureKey. -
Don’tSubscribe to
store.select()by hand in a zoneless app.DoUseselectSignaland read the signal in the template.
TypeScript types usually use PascalCase. Renaming countState to CounterState matches Angular’s own style and makes types easy to spot in a file.
Where to go next
The counter covers the core loop. These four small changes each add one new idea on top of it.
Send data with an action
Use props when an action needs a value. The event name 'Increment By' becomes counterActions.incrementBy.
// counter.actions.ts
import { createActionGroup, emptyProps, props } from '@ngrx/store';
export const counterActions = createActionGroup({
source: 'Counter',
events: {
Increment: emptyProps(),
Decrement: emptyProps(),
Reset: emptyProps(),
'Increment By': props<{ amount: number }>(),
},
});
// counter.reducer.ts
on(counterActions.incrementBy, (state, { amount }) => ({
...state,
count: state.count + amount,
})),
// counter.facade.ts
incrementBy(amount: number): void {
this.store.dispatch(counterActions.incrementBy({ amount }));
}
Let createFeature write the selectors
createFeature takes a name and a reducer and generates a selector for every property in the state. With it, you could delete counter.selector.ts.
import { createFeature, createReducer, on } from '@ngrx/store';
export const counterFeature = createFeature({
name: 'counter',
reducer: createReducer(
initialState,
on(counterActions.increment, (state) => ({ ...state, count: state.count + 1 })),
on(counterActions.decrement, (state) => ({ ...state, count: state.count - 1 })),
on(counterActions.reset, (state) => ({ ...state, count: 0 })),
),
});
// Generated for you:
// counterFeature.selectCounterState
// counterFeature.selectCount
// app.config.ts
provideState(counterFeature),
Save the count with an effect
Effects run after the reducer, so the count they read is already the new one. This one writes it to localStorage on every change, which a reducer must never do.
import { inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { tap, withLatestFrom } from 'rxjs';
import { counterActions } from './counter.actions';
import { selectCount } from './counter.selector';
export const saveCount = createEffect(
(actions$ = inject(Actions), store = inject(Store)) =>
actions$.pipe(
ofType(counterActions.increment, counterActions.decrement, counterActions.reset),
withLatestFrom(store.select(selectCount)),
tap(([, count]) => localStorage.setItem('count', String(count))),
),
{ functional: true, dispatch: false },
);
// app.config.ts
// import * as counterEffects from './counterStore/counter.effects';
// provideEffects(counterEffects),
Test the reducer
Reducers are pure functions, so they are the easiest code in the app to test. No TestBed, no Store, no mocks.
import { counterReducer, initialState } from './counter.reducer'; import { counterActions } from './counter.actions'; describe('counterReducer', () => { it('adds 1 on increment', () => { const next = counterReducer(initialState, counterActions.increment()); expect(next.count).toBe(1); }); it('leaves the old state untouched', () => { counterReducer(initialState, counterActions.increment()); expect(initialState.count).toBe(0); }); it('goes back to 0 on reset', () => { const next = counterReducer({ count: 5 }, counterActions.reset()); expect(next.count).toBe(0); }); });
No comments:
Post a Comment