Publicado em

- 5 minutos de leitura

Angular: Implementando um stepper com rotas

img of Angular: Implementando um stepper com rotas

Criar formulários complexos pode ser um grande desafio, especialmente quando a quantidade de informações exigidas do usuário é muito extensa.

Dividir esses formulários em etapas menores e mais gerenciáveis é uma prática essencial para melhorar a experiência do usuário, reduzir a frustração e aumentar as taxas de conversão da sua aplicação.

Neste artigo, vamos explorar como construir um stepper robusto e escalável no Angular.

Diferente das implementações mais simples que apenas escondem e mostram seções na mesma tela, vamos utilizar o poder do roteamento do Angular em conjunto com o gerenciamento de estado reativo e Signals.

Essa abordagem profissional garante um código muito mais organizado e uma navegação impecável.

O que é um stepper?

Um stepper, também conhecido como wizard, é um padrão clássico de interface de usuário.

Ele atua dividindo um processo longo e intimidador em uma sequência lógica de passos menores.

Geralmente, esse padrão apresenta um indicador visual do progresso na tela, mostrando claramente em qual etapa o usuário está no momento e quantas etapas ainda faltam para a conclusão final do processo.

Para que serve?

O principal objetivo de um stepper é reduzir drasticamente a carga cognitiva do usuário durante o preenchimento de dados.

Ao invés de apresentar um formulário gigantesco com dezenas de campos de uma só vez, o stepper agrupa os campos que possuem relação entre si (como dados pessoais em uma tela e dados de pagamento em outra).

Isso é extremamente utilizado em processos de checkout de lojas virtuais, cadastros complexos em sistemas empresariais ou em configurações iniciais de plataformas (onboarding).

Por que usar stepper com rotas?

Implementar um stepper vinculando cada passo a uma rota real (URLs distintas para cada etapa) traz benefícios significativos para a aplicação.

Primeiramente, isso permite o uso natural dos botões de “voltar” e “avançar” do próprio navegador, sem quebrar a navegação ou fazer o usuário perder os dados preenchidos acidentalmente.

Em segundo lugar, facilita o compartilhamento de links e o acesso direto a uma etapa específica (“deep linking”).

Se o usuário atualizar a página por engano, ele continua exatamente na etapa em que estava.

Além disso, utilizar rotas possibilita o carregamento sob demanda (lazy loading) dos componentes de cada etapa.

Isso significa que o navegador só baixa o código da etapa de pagamento quando o usuário realmente chegar lá, melhorando a performance inicial do sistema.

Por fim, cada componente de rota fica focado apenas em sua própria parte do formulário.

Isso ajuda a isolar a lógica e as validações de cada etapa, tornando o código final muito mais limpo e fácil de dar manutenção.

Imagem com o texto: Curso Formulários com Angular e com a logo do Angular em cor roxa. Logo abaixo existe um botão com o texto "Eu quero!"

Como implementar

Vamos dividir nossa implementação em partes menores, começando pelo coração do nosso formulário: o gerenciamento de estado.

1. Gerenciamento de Estado

Usaremos um serviço injetável padrão para manter o estado do formulário globalmente entre as rotas do stepper. Utilizaremos o NonNullableFormBuilder para criar um formulário fortemente tipado.

   import { inject, Injectable } from '@angular/core'
import { NonNullableFormBuilder, Validators } from '@angular/forms'

@Injectable()
export class FormState {
	private formBuilder = inject(NonNullableFormBuilder)

	private form = this.formBuilder.group({
		personalData: this.formBuilder.group({
			name: ['', Validators.required],
			email: ['', [Validators.required, Validators.email]]
		}),
		paymentData: this.formBuilder.group({
			cardnumber: ['', Validators.required],
			expiry: ['', [Validators.required]],
			cvv: ['', [Validators.required]]
		})
	})

	get personalDataFormGroup() {
		return this.form.controls.personalData
	}

	get paymentDataFormGroup() {
		return this.form.controls.paymentData
	}

	get isFormValid() {
		return this.form.valid
	}
}

2. Definindo as Rotas e o Provider

Ao configurar o roteamento, uma grande sacada é colocar o FormState no array de providers da rota pai.

Isso garante que o serviço seja instanciado quando o stepper iniciar e que todas as rotas filhas compartilhem a mesma instância em memória.

   import { Routes } from '@angular/router'
import { StepperWithRoutes } from './stepper-with-routes'
import { FormState } from './store/form-state'
import { createCanAccessStepGuard } from './guards/can-access-step-guard'
import { Steps } from './enums/steps.enum'

export const routes: Routes = [
	{
		path: '',
		redirectTo: 'personal-data',
		pathMatch: 'full'
	},
	{
		path: '',
		component: StepperWithRoutes,
		providers: [FormState], // Isolamento da instância acontece aqui
		children: [
			{
				path: 'personal-data',
				canActivate: [createCanAccessStepGuard(Steps.PersonalData)],
				loadComponent: () => import('./steps/personal-data').then((m) => m.PersonalData)
			},
			{
				path: 'payment-data',
				canActivate: [createCanAccessStepGuard(Steps.PaymentData)],
				loadComponent: () => import('./steps/payment-data').then((m) => m.PaymentData)
			}
		]
	}
]

3. Protegendo o fluxo com Guards

Para evitar que o usuário acesse a etapa de pagamento sem antes preencher os dados pessoais, criamos um Guard funcional utilizando a função inject().

   import { CanActivateFn, RedirectCommand, Router } from '@angular/router'
import { inject } from '@angular/core'
import { Steps } from '../enums/steps.enum'
import { FormState } from '../store/form-state'

export const createCanAccessStepGuard = (step: Steps) => {
	const canAccessStepGuard: CanActivateFn = () => {
		const formState = inject(FormState)
		const router = inject(Router)

		if (step === Steps.PaymentData && formState.personalDataFormGroup.valid) {
			return true
		}

		if (step === Steps.PersonalData) {
			return true
		}

		return new RedirectCommand(router.parseUrl('/stepper/personal-data'))
	}

	return canAccessStepGuard
}

4. O Componente Contêiner do Stepper

O componente principal define o layout visual das etapas e usa o router-outlet para renderizar o conteúdo. Usamos Signals (computed) para derivar qual é o passo atual de forma limpa.

   import { Component, computed, inject, ChangeDetectionStrategy } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { Router, RouterOutlet, RouterLink, NavigationEnd } from '@angular/router'
import { filter } from 'rxjs'
import { Steps } from './enums/steps.enum'

@Component({
	selector: 'app-stepper-with-routes',
	imports: [RouterOutlet, RouterLink],
	changeDetection: ChangeDetectionStrategy.OnPush,
	template: `
		<ul class="steps">
			<li routerLink="personal-data" [class.active]="currentStep() >= 1">Dados Pessoais</li>
			<li routerLink="payment-data" [class.active]="currentStep() >= 2">Pagamento</li>
		</ul>

		<div class="step-content">
			<router-outlet></router-outlet>
		</div>
	`
})
export class StepperWithRoutes {
	private router = inject(Router)

	private lastNavigation = toSignal(
		this.router.events.pipe(filter((event) => event instanceof NavigationEnd))
	)

	private stepNameBySegmentUrl = computed(() => {
		return this.lastNavigation()?.urlAfterRedirects.split('/').pop() as Steps
	})

	protected currentStep = computed(() => {
		return this.stepNameBySegmentUrl() === Steps.PaymentData ? 2 : 1
	})
}

5. O Componente de Etapa Filha

Por fim, cada componente de passo só precisa se preocupar com a sua parte do formulário, injetando o FormState.

   import { Component, inject, ChangeDetectionStrategy } from '@angular/core'
import { ReactiveFormsModule } from '@angular/forms'
import { RouterLink } from '@angular/router'
import { FormState } from '../../store/form-state'

@Component({
	selector: 'app-payment-data',
	imports: [ReactiveFormsModule, RouterLink],
	changeDetection: ChangeDetectionStrategy.OnPush,
	template: `
		<form [formGroup]="form">
			<h3>Dados de Pagamento</h3>

			<input type="text" formControlName="cardnumber" placeholder="Número do cartão" />

			<button routerLink="../personal-data">Voltar</button>
			<button (click)="submit()">Finalizar</button>
		</form>
	`
})
export class PaymentData {
	protected formState = inject(FormState)
	protected form = this.formState.paymentDataFormGroup

	submit() {
		if (this.formState.isFormValid) {
			// Enviar dados para a API
		}
	}
}

Conclusão

Implementar um stepper baseado em rotas no Angular eleva drasticamente a qualidade estrutural da sua aplicação.

Essa abordagem garante que o código permaneça modular, enquanto proporciona uma experiência de navegação nativa, segura e previsível para o usuário final que preenche o formulário.

Ao combinar funcionalidades poderosas como o Router, validações reativas, Guards funcionais e Signals para a reatividade da interface, construímos uma fundação sólida de front-end.

Essa arquitetura não só facilita a adição de novas etapas no futuro, mas também serve como um modelo definitivo para outros fluxos complexos dentro de qualquer projeto Angular moderno.

Assine nossa Newsletter

Receba novos posts como esse na sua caixa de e-mail!

Sobre o autor

Author's photo
Henrique Custódia Arquiteto Frontend, entusiasta Angular, cat lover, criador de conteúdo e fundador da Code Dimension!