Complete Tutorial: BehaviorSubject Stepper with Async Pipe
This tutorial teaches from scratch how to build a 3-step Angular stepper using
BehaviorSubject to store state and the async pipe to consume that
state in HTML.
Build a screen with 3 steps: account, address, and plan. The user fills in the fields, clicks next/back, and the complete state appears as JSON on the screen.
1. Core concepts
What is a stepper?
A stepper is an interface split into steps. Instead of showing one large form, you show one part at a time.
In this tutorial, the wizard will have 3 steps:
- Account: name and email.
- Address: city and street.
- Plan: plan type and terms acceptance.
What is state?
State is the current memory of the screen. In this case, the state stores which step the user is on and which data they have already typed.
{
currentStep: 1,
account: {
name: 'Ana',
email: 'ana@email.com'
},
address: {
city: 'Sao Paulo',
street: 'Street A'
},
plan: {
plan: 'pro',
acceptTerms: true
}
}
What is an Observable?
An Observable is a source of values over time. It can emit one value now,
another value later, and so on.
Think of it like a subscription: you subscribe to the source and receive notifications when
something new appears. In RxJS, that subscription is made with subscribe().
observable.subscribe((value) => {
console.log(value);
});
What is BehaviorSubject?
BehaviorSubject is a special type of RxJS Observable. It always stores the most
recent value.
It has two important characteristics:
- It needs an initial value.
- Anyone who subscribes immediately receives the current value.
const counter = new BehaviorSubject(0);
counter.subscribe((value) => {
console.log(value);
});
counter.next(1);
counter.next(2);
In this example, the first received value is 0, because the
BehaviorSubject starts with an initial value.
What is a pipe in Angular?
A pipe is an Angular tool for transforming or processing a value in HTML.
{{ name | uppercase }}
If name is 'john', the uppercase pipe displays
'JOHN'.
What is the async pipe?
The async pipe is a special pipe for working with an Observable
directly in HTML.
It does this automatically:
- Calls
subscribe()on the Observable. - Gets the latest emitted value.
- Updates the HTML when a new value arrives.
- Calls
unsubscribe()when the component leaves the screen.
state$ | async means: "get the current value emitted by
state$".
2. Architecture used
The architecture in this example separates the screen into four main responsibilities:
model, store, component, and template.
shared/wizard.models.ts
features/behavior-subject-stepper/behavior-subject-stepper.store.ts
features/behavior-subject-stepper/behavior-subject-stepper.component.ts
features/behavior-subject-stepper/behavior-subject-stepper.component.html
This separation avoids putting all the logic inside the HTML or inside the component. Each file has a clear role.
Responsibility of each part
Model
Defines the shape of the data. This is where types such as WizardState,
AccountData, AddressData, PlanData, and the
initial state live.
Store
Stores the state and centralizes update rules. This is where methods like
updateAccountName(), next(), previous(), and
reset() live.
Component
Bridges Angular and the store. It injects the store and exposes state$ to
the HTML. It does not need to know the details of how each field is updated.
Template
Renders the interface. It reads the state with the async pipe and calls
store methods when the user interacts with inputs and buttons.
Architecture flow
The main direction of the data is this:
Store stores the state
|
state$ emits the state
|
Template receives it with async pipe
|
User interacts
|
Template calls a store method
|
Store emits a new state
This creates a predictable cycle. The screen does not mutate the state object directly. It always asks the store to change it.
Why not put everything in the component?
You could build the whole wizard like this:
export class StepperComponent {
currentStep = 1;
name = '';
email = '';
city = '';
street = '';
plan = 'basic';
acceptTerms = false;
next(): void {
this.currentStep++;
}
}
For a very small example, this works. The problem appears when the screen grows:
- The component mixes state, business rules, and presentation.
- The update rules become harder to test.
- It becomes easier to change the state in random ways.
- If another component needs the same state, you need to duplicate or improvise.
With a store, the rule is centralized. The HTML does not need to know how to build the new state. It only calls an intention:
store.updateAccountName($event)
store.next()
store.reset()
Local store, not global store
In this example, the store is local to the feature. Notice this part of the component:
providers: [BehaviorSubjectStepperStore]
This means Angular creates a store instance for this component. The state is not global to the whole application. It belongs to this screen.
This is a good choice for a wizard because the state usually only matters while the user is on that screen. If the user leaves and comes back, a new instance can start from the initial state.
Use a local store when the state belongs to one screen or feature. Use a global store when the state must be shared by many parts of the application, such as logged-in user data, permissions, or a shopping cart.
Why does the store expose state$ instead of the BehaviorSubject directly?
The store creates this:
private readonly stateSubject = new BehaviorSubject<WizardState>(initialWizardState);
But exposes this:
readonly state$ = this.stateSubject.asObservable();
The reason is encapsulation. Outside the store, nobody should do:
stateSubject.next(anything);
If that were allowed, any place in the application could break the state. By exposing only
state$, outside code can only observe. To change something, it must call the
official store methods.
Architecture in one sentence
The template shows the state, the user triggers events, the store changes the state, and
the async pipe delivers the new state back to the template.
3. Creating the wizard model
First, create a file to represent the wizard data. Example:
src/app/shared/wizard.models.ts.
export type WizardStep = 1 | 2 | 3;
export interface AccountData {
name: string;
email: string;
}
export interface AddressData {
city: string;
street: string;
}
export interface PlanData {
plan: 'basic' | 'pro' | 'enterprise';
acceptTerms: boolean;
}
export interface WizardState {
currentStep: WizardStep;
account: AccountData;
address: AddressData;
plan: PlanData;
}
export const initialWizardState: WizardState = {
currentStep: 1,
account: {
name: '',
email: '',
},
address: {
city: '',
street: '',
},
plan: {
plan: 'basic',
acceptTerms: false,
},
};
export function nextStep(step: WizardStep): WizardStep {
return step === 3 ? 3 : ((step + 1) as WizardStep);
}
export function previousStep(step: WizardStep): WizardStep {
return step === 1 ? 1 : ((step - 1) as WizardStep);
}
This file defines four things:
- Which steps exist:
1 | 2 | 3. - The data shape of each step.
- The initial state.
- How to go next and previous without leaving the allowed range.
4. Creating the BehaviorSubject store
Now create the store. It will be an Angular service responsible for storing and updating the state.
Example file: behavior-subject-stepper.store.ts.
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import {
initialWizardState,
nextStep,
previousStep,
WizardState,
} from '../../shared/wizard.models';
@Injectable()
export class BehaviorSubjectStepperStore {
private readonly stateSubject = new BehaviorSubject<WizardState>(initialWizardState);
readonly state$ = this.stateSubject.asObservable();
updateAccountName(name: string): void {
const { account } = this.stateSubject.value;
this.patchState({ account: { ...account, name } });
}
updateAccountEmail(email: string): void {
const { account } = this.stateSubject.value;
this.patchState({ account: { ...account, email } });
}
updateAddressCity(city: string): void {
const { address } = this.stateSubject.value;
this.patchState({ address: { ...address, city } });
}
updateAddressStreet(street: string): void {
const { address } = this.stateSubject.value;
this.patchState({ address: { ...address, street } });
}
updatePlanName(plan: WizardState['plan']['plan']): void {
const state = this.stateSubject.value;
this.patchState({ plan: { ...state.plan, plan } });
}
updateAcceptTerms(acceptTerms: boolean): void {
const { plan } = this.stateSubject.value;
this.patchState({ plan: { ...plan, acceptTerms } });
}
next(): void {
const state = this.stateSubject.value;
this.patchState({ currentStep: nextStep(state.currentStep) });
}
previous(): void {
const state = this.stateSubject.value;
this.patchState({ currentStep: previousStep(state.currentStep) });
}
reset(): void {
this.stateSubject.next(initialWizardState);
}
private patchState(partial: Partial<WizardState>): void {
this.stateSubject.next({
...this.stateSubject.value,
...partial,
});
}
}
Explaining the store piece by piece
private readonly stateSubject = new BehaviorSubject<WizardState>(initialWizardState);
This is where the BehaviorSubject is created. It starts with
initialWizardState. That is why the screen already has a value from the first
render.
readonly state$ = this.stateSubject.asObservable();
Here the store exposes the state as an Observable. The component can listen to
the state, but it cannot call next() directly.
asObservable()?
To protect the state. Code outside the store should request changes by calling methods
like updateAccountName(), next(), and reset(). This
keeps update rules centralized.
private patchState(partial: Partial<WizardState>): void {
this.stateSubject.next({
...this.stateSubject.value,
...partial,
});
}
patchState() gets the current state, merges it with the new part, and emits a
new state using next().
Example: if the current state is on step 1 and you call
patchState({ currentStep: 2 }), the rest of the state stays the same, but
currentStep changes to 2.
5. Creating the component
The component is small because the store does almost all the work.
Example file: behavior-subject-stepper.component.ts.
import { AsyncPipe, JsonPipe } from '@angular/common';
import { Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { BehaviorSubjectStepperStore } from './behavior-subject-stepper.store';
@Component({
selector: 'app-behavior-subject-stepper',
imports: [AsyncPipe, FormsModule, JsonPipe],
providers: [BehaviorSubjectStepperStore],
templateUrl: './behavior-subject-stepper.component.html',
})
export class BehaviorSubjectStepperComponent {
readonly store = inject(BehaviorSubjectStepperStore);
readonly state$ = this.store.state$;
}
Explaining the component
imports: [AsyncPipe, FormsModule, JsonPipe]
AsyncPipe: allows| asyncin the HTML.FormsModule: allowsngModel.JsonPipe: allows| jsonto display the state.
providers: [BehaviorSubjectStepperStore]
This creates a new store instance for this component. When the component leaves the screen, this store also stops being used.
readonly store = inject(BehaviorSubjectStepperStore);
Injects the store into the component.
readonly state$ = this.store.state$;
Exposes the store Observable to the HTML. The template will consume this
state$ with | async.
6. Creating the HTML with async pipe
Now comes the full template. It renders the correct step, updates fields, and displays the state JSON.
Example file: behavior-subject-stepper.component.html.
@if (state$ | async; as state) {
<section class="stepper-page">
<header>
<p class="eyebrow">Approach 1</p>
<h1>BehaviorSubject Store</h1>
<p>Local state with explicit RxJS in the service.</p>
</header>
<div class="step-indicator">
<span [class.active]="state.currentStep === 1">1 Account</span>
<span [class.active]="state.currentStep === 2">2 Address</span>
<span [class.active]="state.currentStep === 3">3 Plan</span>
</div>
@switch (state.currentStep) {
@case (1) {
<form class="panel">
<label>
Name
<input
[ngModel]="state.account.name"
(ngModelChange)="store.updateAccountName($event)"
name="bs-name"
/>
</label>
<label>
Email
<input
[ngModel]="state.account.email"
(ngModelChange)="store.updateAccountEmail($event)"
name="bs-email"
/>
</label>
</form>
}
@case (2) {
<form class="panel">
<label>
City
<input
[ngModel]="state.address.city"
(ngModelChange)="store.updateAddressCity($event)"
name="bs-city"
/>
</label>
<label>
Street
<input
[ngModel]="state.address.street"
(ngModelChange)="store.updateAddressStreet($event)"
name="bs-street"
/>
</label>
</form>
}
@case (3) {
<form class="panel">
<label>
Plan
<select
[ngModel]="state.plan.plan"
(ngModelChange)="store.updatePlanName($event)"
name="bs-plan"
>
<option value="basic">Basic</option>
<option value="pro">Pro</option>
<option value="enterprise">Enterprise</option>
</select>
</label>
<label class="check">
<input
type="checkbox"
[ngModel]="state.plan.acceptTerms"
(ngModelChange)="store.updateAcceptTerms($event)"
name="bs-terms"
/>
I accept the terms
</label>
</form>
}
}
<nav class="actions">
<button
type="button"
(click)="store.previous()"
[disabled]="state.currentStep === 1"
>
Back
</button>
<button
type="button"
(click)="store.next()"
[disabled]="state.currentStep === 3"
>
Next
</button>
<button type="button" class="secondary" (click)="store.reset()">
Reset
</button>
</nav>
<pre>{{ state | json }}</pre>
</section>
}
The most important line
@if (state$ | async; as state) {
Read it like this:
If state$ emits a value, get that value and call it state.
This line avoids writing subscribe() manually in the component.
Step indicator
<span [class.active]="state.currentStep === 1">1 Account</span>
If state.currentStep is 1, Angular adds the
active class. This visually highlights the current step.
Choosing which form appears
@switch (state.currentStep) {
@case (1) { ... }
@case (2) { ... }
@case (3) { ... }
}
If currentStep is 1, it shows account fields. If it is
2, it shows address fields. If it is 3, it shows plan fields.
Reading and changing fields
<input
[ngModel]="state.account.name"
(ngModelChange)="store.updateAccountName($event)"
name="bs-name"
/>
This line has two directions:
[ngModel]sends the state value to the input.(ngModelChange)sends the input change to the store.
The $event is the new value typed by the user.
Buttons
<button (click)="store.previous()" [disabled]="state.currentStep === 1">
Back
</button>
When clicked, it calls store.previous(). If it is already on step 1, the button
is disabled.
<button (click)="store.next()" [disabled]="state.currentStep === 3">
Next
</button>
When clicked, it calls store.next(). If it is already on step 3, the button is
disabled.
<button (click)="store.reset()">Reset</button>
Calls reset() and returns the state to initialWizardState.
Showing the state as JSON
<pre>{{ state | json }}</pre>
The json pipe turns the state object into formatted text on the
screen. This is useful for learning and debugging.
7. How it would look without async pipe
Without the async pipe, you would have to manually do in TypeScript what
Angular was doing in HTML.
With async pipe
The component stays small:
export class BehaviorSubjectStepperComponent {
readonly store = inject(BehaviorSubjectStepperStore);
readonly state$ = this.store.state$;
}
The HTML subscribes to the Observable:
@if (state$ | async; as state) {
<p>Current step: {{ state.currentStep }}</p>
}
Without async pipe
The component must subscribe and unsubscribe manually:
import { Component, OnDestroy, OnInit, inject } from '@angular/core';
import { Subscription } from 'rxjs';
import { WizardState } from '../../shared/wizard.models';
import { BehaviorSubjectStepperStore } from './behavior-subject-stepper.store';
export class BehaviorSubjectStepperComponent
implements OnInit, OnDestroy {
readonly store = inject(BehaviorSubjectStepperStore);
state?: WizardState;
private subscription?: Subscription;
ngOnInit(): void {
this.subscription = this.store.state$.subscribe((state) => {
this.state = state;
});
}
ngOnDestroy(): void {
this.subscription?.unsubscribe();
}
}
The HTML uses the regular property:
@if (state; as currentState) {
<p>Current step: {{ currentState.currentStep }}</p>
}
It works, but it puts a repetitive responsibility in the component: subscribe, store the
value, and unsubscribe. With the async pipe, Angular does that by itself.
8. Complete update flow
When the user types the name, the flow is this:
- The user types in the input.
- Angular fires
ngModelChange. - The template calls
store.updateAccountName($event). - The store builds a new state.
- The store calls
stateSubject.next(newState). - The
state$Observable emits that new state. - The
asyncpipe receives the new value. - Angular updates the HTML.
The same reasoning applies to moving to the next step:
- The user clicks
Next. - The template calls
store.next(). - The store calculates the next step with
nextStep(). - The store emits a new state with the updated
currentStep. - The template receives that state through the
async pipe. - The
@switchchanges the visible form.
Final summary
BehaviorSubject
Stores the current state and emits new states when something changes.
state$
Public Observable that the component/template can observe.
state$ | async
Subscribes to the Observable in HTML and gets the emitted value.
as state
Gives the emitted value a local name to use inside the template.
[ngModel]
Shows the value from the state in the input.
(ngModelChange)
Sends the new user-typed value to the store.
store.next(), store.previous(), store.reset()
Methods that change the wizard state.