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.

Final goal:

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:

  1. Account: name and email.
  2. Address: city and street.
  3. 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:

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:

Mental reading:

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:

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.

Practical rule:

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:

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.

Why use 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]
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:

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>
}
Why is the version without async pipe worse here?

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:

  1. The user types in the input.
  2. Angular fires ngModelChange.
  3. The template calls store.updateAccountName($event).
  4. The store builds a new state.
  5. The store calls stateSubject.next(newState).
  6. The state$ Observable emits that new state.
  7. The async pipe receives the new value.
  8. Angular updates the HTML.

The same reasoning applies to moving to the next step:

  1. The user clicks Next.
  2. The template calls store.next().
  3. The store calculates the next step with nextStep().
  4. The store emits a new state with the updated currentStep.
  5. The template receives that state through the async pipe.
  6. The @switch changes 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.