Utilizando o Resource API do Angular 19 em um pequeno buscador

A API Resource, lançada no Angular 19, integra-se aos Signals, permitindo que componentes reajam automaticamente a mudanças nos dados. Isso elimina a necessidade de gerenciar assinaturas de observables ou usar outras soluções complexas. Veja neste texto um exemplo prático aplicado a um buscador.

Utilizando o Resource API do Angular 19 em um pequeno buscador
Resource API - Angular 19

Ainda em modo experimental, o Resource lançado no Angular 19, tem o objetivo de efetuar operações de leitura, reagindo a mudanças de estado de um ou mais signals. Antes desse lançamento, a abordagem mais comum era utilizar o effect para monitorar alterações em signals e, em seguida, chamar uma função para realizar uma consulta, como uma requisição a uma API. O Resource simplifica bastante esse processo e será o foco deste texto.

Note that resource is intended for read operations, not operations which perform mutations. resource will cancel in-progress loads via the AbortSignal when destroyed or when a new request object becomes available, which could prematurely abort mutations.

https://angular.dev/api/core/resource?tab=description

Para testar esse novo recurso, criei um pequeno e simples buscador. A ideia é que conforme o usuário digite, vamos alterando o valor de um signal e essa mudança dispare uma nova requisição à uma API. Consequentemente, o resultado da consulta é exibido na tela.

Construção do cenário de estudo

Para criar o cenário de estudo, utilizei dois projetos:

  • frontend: um projeto Angular (versão 19)
  • backend: uma pequena API com Node.js, sem frameworks.

Criando o projeto Angular

Para criar o projeto Angular, utilizei o seguinte comando e opções:

ng new --skip-git --skip-tests teste_resource
✔ Which stylesheet format would you like to use? Sass (SCSS)
[https://sass-lang.com/documentation/syntax#scss]

✔ Do you want to enable Server-Side Rendering (SSR) and
Static Site Generation (SSG/Prerendering)? No

Após criar o projeto, instalei o pacote Angular Material para ter acesso a uma ampla variedade de componentes visuais. Este pacote não é obrigatório para o uso do resource!

# Acessando o diretório do projeto criado
cd teste_resource

# Adicionando o Angular Material
ng add @angular/material

Criando API para testes

Após criar o projeto Angular com os comandos acima, criei um diretório chamado /server e adicionei dois arquivos que irão compor a pequena API:

💡
O diretório /server pode ficar em qualquer pasta de sua preferência. Não precisa necessariamente ficar no mesmo nível de diretório do projeto Angular.
import { createServer } from 'node:http';
import url from 'url';
import carros from './dados.json' with { type: 'json' };

function delay(atrasoEmMS = 1000) {
  return new Promise((resolve) => {
    setTimeout(() => resolve(), atrasoEmMS);
  });
}

const server = createServer(async (req, res) => {
  // Esse atraso (delay) é proposital para facilitar a
  // análise no frontend.
  await delay();

  const parsedUrl = url.parse(req.url, true);
  const palavraPesquisada = parsedUrl.query['q']
    .trim()
    .toLocaleLowerCase();

  const cabecalhosResposta = {
    'Content-Type': 'application/json; charset=utf-8',
  };

  // Se o valor da query string `q` for `erro`,
  // devolvemos o http status 500
  if (palavraPesquisada === 'erro') {
    res.writeHead(500, cabecalhosResposta);
    res.end(JSON.stringify({ mensagem: 'Erro na API' }));
    return;
  }

  const carrosFiltrados = carros.filter((a) =>
    a.nome.toLocaleLowerCase().includes(palavraPesquisada),
  );

  res.writeHead(200, cabecalhosResposta);
  res.end(JSON.stringify(carrosFiltrados));
});

server.listen(3000, () => console.log('Servidor online'));

index.mjs

Abaixo o arquivo JSON que é utilizado no código acima:

[
  {
    "id": 1,
    "nome": "Toyota Corolla",
    "descricao": "Sedan compacto confiável e eficiente."
  },
  {
    "...": "...outros registros..."
  },
  {
    "id": 49,
    "nome": "Volvo XC90",
    "descricao": "SUV de luxo com foco em segurança."
  },
  {
    "id": 50,
    "nome": "Lexus RX",
    "descricao": "SUV premium com design refinado."
  }
]

dados.json

Essa API de testes está preparada para receber requisições no seguinte formato:

# Pesquisa os carros que começam com "ford"
curl -i "http://localhost:3000?q=ford"

Resultado:

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

[{"id":3,"nome":"Ford Mustang","descricao":"Clássico muscle car americano com potência impressionante."},{"id":16,"nome":"Ford Fusion","descricao":"Modelo híbrido com conforto e eficiência."},{"id":21,"nome":"Ford Explorer","descricao":"SUV médio com bastante espaço."},{"id":24,"nome":"Ford F-150","descricao":"Picape clássica com recursos modernos."},{"id":43,"nome":"Ford Edge","descricao":"Crossover com desempenho confiável."}]%    
💡
No código acima, foi introduzido intencionalmente um atraso de 1 segundo em cada requisição. Esse atraso tem como objetivo permitir o estudo detalhado do comportamento do abortSignal, que será explicado posteriormente.

Agora que a pequena API de testes foi criada, seguimos com foco no projeto Angular.

Preparando o projeto Angular

Sem entrar nos detalhes, a seguir fiz algumas inclusões e alterações no projeto Angular. A ideia foi criar os arquivos necessários, ajustar o estilo visual e efetuar as devidas referências, veja a seguir:

Criando os recursos com Angular CLI:

# Cria o componente <app-buscador>
ng g c buscador

# Cria a interface Carro
ng g i carro

Conteúdo dos arquivos modificados e/ou recém criados:

import { Component } from '@angular/core';
import {
  BuscadorComponent
} from './buscador/buscador.component';

@Component({
  selector: 'app-root',
  imports: [BuscadorComponent],
  templateUrl: './app.component.html',
  styleUrl: './app.component.scss',
})
export class AppComponent {}

app.component

<div class="container">
  <div class="row">
    <div class="col">
      <app-buscador></app-buscador>
    </div>
  </div>
</div>

app.component.html

html, body { height: 100%; }

body {
  display: flex;
  margin: 0;
  width: 100vw;
  font-family: Roboto, "Helvetica Neue", sans-serif;
}

.container {
  width: 100vw;
  margin: 0 auto;
}

.row {
  display: flex;
  flex-wrap: wrap;
}

.col {
  flex: 1 1 auto;
  padding: 15px;
}

styles.scss

export interface Carro {
  id: number;
  nome: string;
  descricao: string;
}

carro.ts

Utilizando o resource para efetuar uma consulta

O mecanismo de busca implementado a seguir funciona da seguinte forma: o usuário digita a palavra desejada em um campo de texto exibido na tela. Assim que o texto inserido atingir pelo menos 3 caracteres, o valor digitado será atribuído a uma variável do tipo WritableSignal. Essa variável, ao ser modificada, dispara a execução da loader, associada ao resource.

Na implementação abaixo deixei vários comentários para facilitar o entendimento. Também incluí um tratamento de erro com o SnackBar do Angular Material.

import { CommonModule } from '@angular/common';
import {
  AfterViewInit,
  Component,
  computed,
  effect,
  ElementRef,
  inject,
  resource,
  ResourceRef,
  signal,
  viewChild,
} from '@angular/core';
import {
  MatFormFieldModule
} from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatListModule } from '@angular/material/list';
import {
  MatProgressBarModule
} from '@angular/material/progress-bar';
import {
  MatSnackBar,
  MatSnackBarModule,
} from '@angular/material/snack-bar';
import {
  debounceTime,
  distinctUntilChanged,
  fromEvent,
  map,
} from 'rxjs';
import { Carro } from '../carro';

@Component({
  selector: 'app-buscador',
  imports: [
    CommonModule,
    MatFormFieldModule,
    MatIconModule,
    MatInputModule,
    MatListModule,
    MatProgressBarModule,
    MatSnackBarModule,
  ],
  templateUrl: './buscador.component.html',
  styleUrl: './buscador.component.scss',
})
export class BuscadorComponent implements AfterViewInit {
  palavraPesquisada = signal<string | null>(null);
  inputBuscador =
    viewChild.required<ElementRef<HTMLInputElement>>(
      'buscador',
    );

  private snackBar = inject(MatSnackBar);

  carrosResource: ResourceRef<Carro[]> = resource({
    request: () => ({
      palavraProcurada: this.palavraPesquisada(),
    }),
    loader: async ({ request, abortSignal }) => {
      const { palavraProcurada } = request;
      const efetuarConsulta =
        palavraProcurada !== null &&
        palavraProcurada.length >= 3;

      if (!efetuarConsulta) {
        return Promise.resolve([]);
      }

      const url = new URL('', 'http://localhost:3000');
      url.searchParams.append('q', palavraProcurada);

      const resposta = await fetch(url, {
        signal: abortSignal,
      });

      // A Promise retornada do fetch() não rejeitará o
      // status do erro HTTP, mesmo que a resposta seja
      // um HTTP 404 ou 500. Em vez disso, ela irá resolver
      // normalmente (com o status ok definido como falso),
      // e só irá rejeitar se houver falha na rede ou se
      // algo impedir a requisição de ser completada.
      //
      // https://developer.mozilla.org/pt-
      // BR/docs/Web/API/Fetch_API/Using_Fetch
      if (!resposta.ok) {
        const { mensagem } = await resposta.json();
        throw mensagem;
      }

      return resposta.json();
    },
  });

  estaCarregando = computed(() =>
    this.carrosResource.isLoading(),
  );

  resultadoBusca = computed(
    () => this.carrosResource.value() ?? [],
  );

  constructor() {
    effect(() => {
      const erro = this.carrosResource.error();
      if (erro) {
        const mensagem =
          typeof erro === 'string' ? erro : 'Erro genérico';

        this.snackBar.open(mensagem, '', { duration: 3e3 });
      }
    });
  }

  ngAfterViewInit(): void {
    this.escutarEventoInputBusca();
  }

  escutarEventoInputBusca() {
    fromEvent(this.inputBuscador().nativeElement, 'input')
      .pipe(
        // Aguarda 250ms após a última alteração de valor
        // do input, antes de emitir o evento.
        debounceTime(250),
        // Abaixo propagamos somente o `.value` do input
        map((event) =>
          (event.target as HTMLInputElement).value.trim(),
        ),
        // Emite o evento somente quando houver uma
        // alteração de valor.
        // A ideia é que o "trim()" em conjunto com
        // o `distinctUntilChanged`, evite disparar
        // consultas quando o usuário adicionar um
        // espaço no final da palavra buscada.
        distinctUntilChanged(),
      )
      .subscribe((valorDoInputBuscador) => {
        // Por fim, atualizamos o `signal` palavraPesquisada.
        // Essa atualização fará com que o `carrosResource`
        // execute a função `loader`.
        this.palavraPesquisada.set(valorDoInputBuscador);
      });
  }
}

buscador.component.ts

<div class="row">
  <div class="col">
    <mat-form-field style="width: 100%">
      <mat-label>Buscador:</mat-label>
      <input
        #buscador
        matInput
        type="text"
        type="search"
        autofocus />
    </mat-form-field>
    @if (estaCarregando()) {
      <mat-progress-bar
        mode="indeterminate"></mat-progress-bar>
    } @else {
      <p>
        Quantidade de registros encontrados:
        <strong>{{ resultadoBusca().length }}</strong>
      </p>
      <mat-list>
        @for (carro of resultadoBusca(); track carro.id) {
          <mat-list-item>
            <mat-icon
              matListItemIcon
              fontIcon="directions_car"></mat-icon>
            <div matListItemTitle>{{ carro.nome }}</div>
            <div matListItemLine>{{ carro.descricao }}</div>
          </mat-list-item>
        } @empty {
          <p>Nenhum registro encontrado!</p>
        }
      </mat-list>
    }
  </div>
</div>

buscador.component.html

Resultado:

Navegador exibindo o conteúdo de localhost:4200, o projeto Angular. Conforme o usuário digita algo no campo de busca, os resultados são exibidos.
Resultado do projeto em execução

Uso do abortSignal

Em um post mais antigo, comentei sobre o uso do AbortController para aplicar timeouts ou cancelar requisições.

Como aplicar um timeout ou cancelar uma requisição com fetch do JavaScript
Cansado de requisições HTTP lentas e travamentos? Aprenda a usar AbortSignal e AbortController no JavaScript para configurar timeouts e cancelar requisições com eficiência, otimizando a performance e a experiência do usuário no seu site. Descubra como implementar essa técnica neste post!

No caso do resource, sempre que o valor retornado pela request (declarado dentro do resource) é alterado, ele executa novamente o loader. Nesse cenário, o abortSignal é utilizado para que o resource cancele a execução anterior, caso ela ainda esteja em andamento, antes de iniciar uma nova. Abaixo, observe o comportamento das requisições sem o uso do abortSignal e com ele:

DevTools do navegador mostrando as requisições que o navegador faz conforme o usuário digita no campo de busca.
Exemplo de uso do resource e fetch sem o uso do abortSignal

Observe acima que, à medida que o usuário digita pausadamente, várias requisições são disparadas sem que nenhuma delas seja cancelada. Por outro lado, ao utilizar o abortSignal, à medida que o usuário digita pausadamente, a requisição anterior é cancelada caso ainda esteja em andamento:

DevTools do navegador mostrando as requisições canceladas quando uma nova é efetuada sem que a anterior tenha sido concluída.
Utilizando o abortSignal com o resource

Considerações

Ainda em modo experimental, dependendo da resposta da comunidade e do time de desenvolvimento do Angular, pode ser que essa funcionalidade seja alterada. Então use com cuidado enquanto estiver em modo experimental.

A capacidade de reatividade do signal é incrível. Até então, tínhamos que "escutar" por alterações de valores e então iniciar o fluxo para obter dados externos, como de uma API. O resource claramente simplificou esse processo, além da capacidade de cancelar requisições pendentes através do abortSignal, que é um diferencial importante, evitando problemas comuns em aplicações que lidam com múltiplas requisições.