import { Injectable } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
@Injectable()
export class SignupFormService {
readonly form: FormGroup;
constructor(private fb: FormBuilder) {
this.form = this.fb.group({
account: this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
}),
profile: this.fb.group({
firstName: ['', Validators.required],
lastName: ['', Validators.required],
company: [''],
}),
confirm: this.fb.group({
acceptedTerms: [false, Validators.requiredTrue],
}),
});
}
groupFor(key: string): FormGroup {
return this.form.get(key) as FormGroup;
}
payload(): unknown {
return this.form.getRawValue();
}
reset(): void {
this.form.reset();
}
}
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { distinctUntilChanged, map } from 'rxjs/operators';
import { SignupFormService } from './signup-form.service';
export interface WizardStep {
label: string;
groupKey: string;
}
export const STEPS: WizardStep[] = [
{ label: 'Account', groupKey: 'account' },
{ label: 'Profile', groupKey: 'profile' },
{ label: 'Confirm', groupKey: 'confirm' },
];
@Injectable()
export class SignupStepperService {
private readonly index$ = new BehaviorSubject<number>(0);
readonly steps = STEPS;
readonly currentIndex$ = this.index$.pipe(distinctUntilChanged());
readonly currentStep$: Observable<WizardStep> = this.currentIndex$.pipe(
map((i) => STEPS[i]),
);
readonly canGoBack$ = this.currentIndex$.pipe(map((i) => i > 0));
readonly isLastStep$ = this.currentIndex$.pipe(
map((i) => i === STEPS.length - 1),
);
constructor(private formService: SignupFormService) {}
next(): void {
const i = this.index$.value;
const group = this.formService.groupFor(STEPS[i].groupKey);
if (group.invalid) {
group.markAllAsTouched();
return;
}
if (i < STEPS.length - 1) {
this.index$.next(i + 1);
}
}
back(): void {
const i = this.index$.value;
if (i > 0) {
this.index$.next(i - 1);
}
}
reset(): void {
this.index$.next(0);
}
}
import { Component } from '@angular/core';
import { combineLatest } from 'rxjs';
import { map } from 'rxjs/operators';
import { SignupFormService } from './signup-form.service';
import { SignupStepperService } from './signup-stepper.service';
@Component({
selector: 'app-signup-wizard',
templateUrl: './signup-wizard.component.html',
providers: [SignupFormService, SignupStepperService],
})
export class SignupWizardComponent {
readonly form = this.formService.form;
readonly steps = this.stepper.steps;
readonly vm$ = combineLatest([
this.stepper.currentIndex$,
this.stepper.currentStep$,
this.stepper.canGoBack$,
this.stepper.isLastStep$,
]).pipe(
map(([index, step, canGoBack, isLastStep]) => ({
index,
step,
canGoBack,
isLastStep,
progress: Math.round(((index + 1) / this.steps.length) * 100),
})),
);
constructor(
private formService: SignupFormService,
private stepper: SignupStepperService,
) {}
next(): void {
this.stepper.next();
}
back(): void {
this.stepper.back();
}
onSubmit(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
console.log('signup payload', this.formService.payload());
}
}
<form [formGroup]="form" (ngSubmit)="onSubmit()" *ngIf="vm$ | async as vm">
<div class="progress" role="progressbar" [attr.aria-valuenow]="vm.progress">
<div class="bar" [style.width.%]="vm.progress"></div>
</div>
<ol class="steps">
<li *ngFor="let s of steps; let i = index" [class.active]="i === vm.index">
{{ s.label }}
</li>
</ol>
<section [formGroupName]="vm.step.groupKey">
<ng-container [ngSwitch]="vm.step.groupKey">
<div *ngSwitchCase="'account'">
<input formControlName="email" type="email" placeholder="Email" />
<input formControlName="password" type="password" placeholder="Password" />
</div>
<div *ngSwitchCase="'profile'">
<input formControlName="firstName" placeholder="First name" />
<input formControlName="lastName" placeholder="Last name" />
<input formControlName="company" placeholder="Company (optional)" />
</div>
<div *ngSwitchCase="'confirm'">
<label>
<input formControlName="acceptedTerms" type="checkbox" />
I accept the terms
</label>
</div>
</ng-container>
</section>
<footer>
<button type="button" (click)="back()" [disabled]="!vm.canGoBack">Back</button>
<button type="button" (click)="next()" *ngIf="!vm.isLastStep">Next</button>
<button type="submit" *ngIf="vm.isLastStep" [disabled]="form.invalid">
Create account
</button>
</footer>
</form>
This snippet builds a multi-step signup wizard where every step edits slices of one shared FormGroup, and a small service owns the navigation state. The key idea is that a wizard is fundamentally a single form split into views: rather than giving each step its own form and stitching values together at the end, one root FormGroup holds nested groups (account, profile, confirm) so the final payload is always the complete, validated form value.
In signup-form.service.ts, the SignupFormService constructs that root form once with FormBuilder. Because the form lives in a shared service rather than a component, step components can come and go without losing state, and the service can expose helpers like groupFor to hand each step its own nested group. This inversion — form in the service, views in the components — is what makes deep linking and back-navigation safe.
The SignupStepperService in signup-stepper.service.ts tracks the current index with a BehaviorSubject and derives currentStep$, canGoBack$, and isLastStep$ as observables. Navigation is guarded: next refuses to advance while the active step's FormGroup is invalid, calling markAllAsTouched() so validation errors surface immediately instead of silently blocking the button. This keeps the "you can't proceed with bad data" rule in one place instead of scattered across templates.
The steps themselves are declared as data in STEPS, each mapping a label to the nested control key it validates. That makes the wizard configurable — reordering or inserting a step is a data change, not a control-flow rewrite.
In signup-wizard.component.ts, the container wires the two services together. It renders the active step via *ngComponentOutlet or a switch, shows a progress indicator from currentStep$, and only enables submit when the whole root form is valid. onSubmit reads form.getRawValue() for the complete payload.
The trade-off is a little indirection: state lives outside components, so the services must be provided at the right level (here, scoped to the wizard route) to avoid leaking one signup's state into another. In exchange the pattern scales cleanly to conditional steps, resumable drafts, and per-step validation without turning the container into a giant stateful component.
Related snips
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
timeout: 15000,
Axios API client with interceptors
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface FilterState {
search: string
category: string | null
Zustand for lightweight state management
import SwiftUI
struct ContentView: View {
@State private var username = ""
@State private var isLoggedIn = false
@StateObject private var viewModel = LoginViewModel()
SwiftUI declarative UI with state management
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Form Validation Example</title>
<style>
HTML forms with validation and accessibility
Share this code
Here's the card — post it anywhere.