Destino Dev

Blog

Sincronizar filtros na URL com Angular: Signals + Router + RxJS (para iniciantes)

Por John Pires · 16 de março de 2026

Por que aprender isso em 2026

Contexto técnico e relevância

Em 2026 os applications web continuam combinando experiência reativa com URLs que representam estado. URLs serializam filtros, mantêm navegabilidade e permitem compartilhar telas. Entender como manter filtros sincronizados com a URL evita estados inconsistentes, melhora SEO parcial, permite bookmarking e torna aplicações previsíveis.

Analogia simples

Pense na URL como o painel frontal de um carro que mostra a velocidade e o combustível — os filtros são o motor. O painel (URL) deve refletir o que o motor (estado da UI) está fazendo. Se um mecânico (o código) mexe no motor, o painel precisa atualizar; se alguém gira o painel (muda a URL manualmente), o motor precisa reagir. Sincronizar filtros é sobre garantir que painel e motor sigam a mesma versão da verdade.

Este artigo explora por que Signals (estado reativo nativo do Angular) + Router + RxJS formam uma combinação sólida: Signals para estado local, Router para persistência na URL, RxJS para debouncing, comparação e evitar loop de navegação.

Configuração do ambiente

Requisitos mínimos

Você precisa ter Node.js (>=18 recomendado), Angular CLI (>=16) e um projeto Angular standalone ou tradicional. Vamos usar APIs modernas: signals (signal, effect), inject(), Router e ActivatedRoute. RxJS será usado para operadores como debounceTime, distinctUntilChanged e Subject.

Criando um projeto rápido

Passos práticos para iniciar um projeto standalone. Abra o terminal e execute:

npm init @angular/app my-filters-app -- --standalone

O comando acima gera um projeto com components standalone. Se preferir nova versão do CLI, use ng new com flags apropriadas.

Dependências que vamos usar

Além do Angular padrão, você precisa apenas do rxjs (vem com Angular). Certifique-se das versões compatíveis no package.json. Não usaremos bibliotecas externas para o gerenciamento de query params — o Router nativo é suficiente.

Hello World progressivo

Objetivo do exemplo

Construiremos um pequeno componente que:

  • Tem um campo de busca (search).

  • Sincroniza o valor com a query string (ex: ?search=term).

  • Evita loop de navegação e aplica debounce em digitação.

Componente mínimo com signals e router

Contexto: este componente standalone demonstra sincronização unidirecional: URL -> signal e signal -> URL com proteção contra loops. Leia o código, depois explico linha a linha.

import { Component, signal, effect, inject } from '@angular/core';
import { Router, ActivatedRoute } from '@angular/router';
import { Subject } from 'rxjs';
import { debounceTime } from 'rxjs/operators';

@Component({
  selector: 'app-search-sync',
  standalone: true,
  template: `
    
  `
})
export class SearchSyncComponent {
  private router = inject(Router);
  private route = inject(ActivatedRoute);

  search = signal('');
  private updatingFromUrl = signal(false);
  private write$ = new Subject();

  constructor() {
    // 1) Escuta a query param e atualiza o signal
    this.route.queryParamMap.subscribe(map => {
      const s = map.get('search') ?? '';
      this.updatingFromUrl.set(true);
      this.search.set(s);
      this.updatingFromUrl.set(false);
    });

    // 2) Quando o signal muda, empurra para um Subject para debouncing
    effect(() => this.write$.next(this.search()));

    // 3) Fluxo RxJS que navega alterando a URL (debounced)
    this.write$.pipe(debounceTime(300)).subscribe(value => {
      if (this.updatingFromUrl()) return; // evita loop
      this.router.navigate([], {
        relativeTo: this.route,
        queryParams: { search: value || null },
        queryParamsHandling: 'merge'
      });
    });
  }

  onInput(val: string) {
    this.search.set(val);
  }
}

O que acontece: a subscription em queryParamMap atualiza o signal quando a URL muda. O effect empurra o novo valor em um Subject que passa por debounceTime antes de chamar router.navigate. A flag updatingFromUrl evita que o navigate re-dispare update desnecessário.

O que muda em cada passo

Quando o usuário digita, o input chama onInput que atualiza o signal. O effect empurra esse valor ao Subject e, após debounce, o subscriber chama router.navigate. Quando a URL muda (por qualquer motivo), a subscription em queryParamMap atualiza o signal sem reemitir navigation.

Mini-projeto prático: lista com filtros sincronizados

Requisitos do mini-projeto

Construiremos uma tela de listagem fictícia com filtros:

  • search (string)

  • tag (categoria) — string

  • page (número)

Objetivos concretos:

  1. Inicializar filtros a partir da URL.

  2. Atualizar URL quando filtros mudam.

  3. Aplicar debounce no search e evitar loop entre URL <-> signals.

  4. Mostrar como integrar com um serviço que retorna resultados (mock).

Modelo de estado e utilitários

Contexto: definimos tipos e funções utilitárias para serializar/limpar query params.

export interface Filters {
  search: string;
  tag: string | null;
  page: number;
}

export function cleanQueryParams(f: Filters) {
  // Remove valores vazios para evitar URLs poluídas
  const qp: any = {};
  if (f.search) qp.search = f.search;
  if (f.tag) qp.tag = f.tag;
  if (f.page && f.page !== 1) qp.page = f.page;
  return qp;
}

O que faz: a função cleanQueryParams converte o estado em query params, omitindo valores padrão. Isso mantém a URL curta e previsível.

Componente completo do mini-projeto

Contexto: o componente abaixo junta tudo: signals, Router, ActivatedRoute, RxJS e um serviço mock que devolve itens filtrados. Cole e rode dentro de um projeto Angular standalone.

import { Component, signal, effect, computed, inject } from '@angular/core';
import { Router, ActivatedRoute } from '@angular/router';
import { Subject, of } from 'rxjs';
import { debounceTime, switchMap } from 'rxjs/operators';

// Simula serviço que retorna itens
function fetchItems(filters: { search: string; tag: string | null; page: number; }) {
  const all = Array.from({ length: 50 }).map((_, i) => ({ id: i + 1, title: `Item ${i + 1}`, tag: i % 2 ? 'a' : 'b' }));
  const filtered = all
    .filter(x => !filters.search || x.title.toLowerCase().includes(filters.search.toLowerCase()))
    .filter(x => !filters.tag || x.tag === filters.tag);
  const pageSize = 10;
  const start = (filters.page - 1) * pageSize;
  return of({ items: filtered.slice(start, start + pageSize), total: filtered.length });
}

@Component({
  selector: 'app-list-filters',
  standalone: true,
  template:
    all
        a
        b
      
      prev
      next
   Total: {{ result()?.total }}{{ item.title }} ({{ item.tag }})

})
export class ListFiltersComponent {
  private router = inject(Router);
  private route = inject(ActivatedRoute);

  // estado local como signal
  filters = signal({ search: '', tag: null, page: 1 });
  private fromUrl = signal(false);

  // stream para debouncing de navegação
  private write$ = new Subject();

  // resultado computado (poderia ser um signal derivado de uma chamada real)
  result = signal<{ items: any[]; total: number } | null>(null);

  constructor() {
    // inicializa a partir da URL
    this.route.queryParamMap.subscribe(map => {
      const f: Filters = {
        search: map.get('search') ?? '',
        tag: map.get('tag'),
        page: +(map.get('page') ?? 1)
      };
      this.fromUrl.set(true);
      this.filters.set(f);
      this.fromUrl.set(false);
    });

    // quando filters muda, empurra para rx stream
    effect(() => this.write$.next(this.filters()));

    // debouce e atualiza URL sem loop
    this.write$.pipe(debounceTime(250)).subscribe(f => {
      if (this.fromUrl()) return;
      this.router.navigate([], { relativeTo: this.route, queryParams: cleanQueryParams(f), queryParamsHandling: 'merge' });
    });

    // sempre que filters mudar, buscar itens (ex: efeito que chama serviço)
    effect(() => {
      const f = this.filters();
      // fetchItems retorna Observable; usamos switchMap-like pattern simplificado
      fetchItems(f).subscribe(res => this.result.set(res));
    });
  }

  setSearch(val: string) { this.filters.update(s => ({ ...s, search: val, page: 1 })); }
  setTag(val: string | null) { this.filters.update(s => ({ ...s, tag: val, page: 1 })); }
  prevPage() { this.filters.update(s => ({ ...s, page: Math.max(1, s.page - 1) })); }
  nextPage() { this.filters.update(s => ({ ...s, page: s.page + 1 })); }
}

O que acontece: o componente lê query params no construtor e popula o signal. A cada mudança local no signal, o efeito empurra os filtros para o Subject que aplica debounce antes de chamar router.navigate. Outro efeito consome o state e chama fetchItems para atualizar a lista mostrada.

Erros comuns e armadilhas

Contexto: aqui estão várias pegadinhas que iniciantes encontrarão ao sincronizar estado e URL.

  • Loop de navegação: chamar router.navigate em resposta a query params sem sinalizar a origem gera loops infinitos. Use uma flag (como fromUrl/updatingFromUrl) ou compare valores antes de navegar.

  • Poluição da URL: não remova valores padrão. Use funções que limpem query params antes de navegar para evitar URLs cheias de parâmetros vazios.

  • Debounce e UX: não debounce por muito tempo. 200–300ms é prática comum para search inputs.

  • Comparação profunda: para objetos complexos você precisa comparar profundamente antes de navegar. Operators como distinctUntilChanged com isEqual ajudam no RxJS.

Dica: teste manualmente atualizando a query string no navegador e observando se a UI reflete o estado. Ferramentas como o Redux DevTools ajudam quando o state está centralizado — para signals, console.log dos sinais funciona bem em dev.

Recursos para continuar estudando

Documentação e guias oficiais

Leia a documentação do Angular sobre Signals, Router e standalone components. Os guias oficiais mostram patterns recomendados e limitações. Complementar com a documentação do RxJS para entender operadores como debounceTime, distinctUntilChanged, switchMap.

Tópicos avançados para seguir

Depois de dominar a sincronização básica, estude:

  • toSignal / toObservable (interop entre Signals e Observables) para simplificar conversões.

  • Gerenciamento de estado com signals e stores locais (signal stores).

  • Estratégias de serialização de filtros complexos (arrays, objetos) para query params.

Conclusão prática

Pontos-chave

Sincronizar filtros com a URL melhora previsibilidade e compartilhabilidade da aplicação. A combinação de signals (estado local), Router (persistência na URL) e RxJS (debounce, comparação, composição) resolve a maioria dos casos sem bibliotecas externas.

Passos práticos finais

Resumo de passos aplicáveis agora:

  1. Defina um estado (signal) que represente seus filtros.

  2. Leia query params no construtor e atualize o signal (marque origem pra evitar loop).

  3. Empurre mudanças do signal para um Subject; aplique debounce e distinct antes de chamar router.navigate.

  4. Use cleanQueryParams para evitar parâmetros desnecessários na URL.

Implementando essas etapas você terá filtros que persistem na URL, reagem a mudanças manuais da barreira do navegador e evitam loops de navegação. Para iniciantes: comece pequeno (um filtro), depois escale para múltiplos parâmetros e tipos mais complexos.

Este artigo forneceu código funcional, explicações e dicas práticas para aplicar imediatamente. A abordagem proposta é incremental, segura contra loops e compatível com aplicações modernas em Angular.

Assuntos: Angular, signals, rxjs, router, standalone

Leia também