Guides / Tutorials

A dog breed picker in Angular

By the Dog Image API team. Updated .

In Angular, a breed picker takes one standalone component. Inject HttpClient, load GET /api/v1/breeds into a signal with toSignal, filter it with a computed value, and show the front three-quarter preview of the selected breed with its credit link.

The code below targets Angular 19 or newer, where components are standalone and ng new sets up the project without modules.

Set up

npm install -g @angular/cli   # skip if you already have it
ng new breed-picker --style=css --ssr=false
cd breed-picker

The global install is optional: npx @angular/cli new breed-picker works too. Answer the prompts as you like. This guide only touches three files.

Provide HttpClient

Open src/app/app.config.ts and add provideHttpClient:

import { ApplicationConfig, provideZoneChangeDetection } from "@angular/core";
import { provideHttpClient } from "@angular/common/http";

export const appConfig: ApplicationConfig = {
  providers: [
    provideZoneChangeDetection({ eventCoalescing: true }),
    provideHttpClient(),
  ],
};

If your generated file has a different set of providers, such as the zoneless one in the newest versions, keep them and just add provideHttpClient() to the array.

The component

Replace src/app/app.ts with the following. The template is inline, so there is only one file to read.

import { Component, computed, inject, signal } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { toSignal } from "@angular/core/rxjs-interop";
import { catchError, map, of } from "rxjs";

const API = "https://dogimage.dev";

interface Breed {
  slug: string;
  name: string;
}

interface CatalogState {
  status: "loading" | "ready" | "error";
  breeds: Breed[];
}

@Component({
  selector: "app-root",
  template: `
    <main class="wrap">
      <h1>Pick a breed</h1>

      <label for="search">Search breeds</label>
      <input
        id="search"
        type="search"
        placeholder="Try dachshund or poodle"
        [value]="query()"
        (input)="query.set($any($event.target).value)"
      />

      @if (catalog().status === "error") {
        <p role="alert">The breed list could not be loaded. Reload to try again.</p>
      }

      <div class="layout">
        <ul class="list">
          @for (breed of matches(); track breed.slug) {
            <li>
              <button
                type="button"
                [attr.aria-pressed]="selected()?.slug === breed.slug"
                [class.active]="selected()?.slug === breed.slug"
                (click)="selected.set(breed)"
              >
                {{ breed.name }}
              </button>
            </li>
          } @empty {
            @if (catalog().status === "ready") {
              <li>No breed matches.</li>
            }
          }
        </ul>

        <section aria-live="polite">
          @if (selected(); as breed) {
            <figure>
              <img
                [src]="previewUrl(breed.slug)"
                [alt]="breed.name + ', AI-generated illustration, front three-quarter view'"
                width="256"
                height="256"
              />
              <figcaption>
                <a [href]="creditUrl(breed.slug)">{{ breed.name }} image: Dog Image API</a>
              </figcaption>
            </figure>
          } @else {
            <p>Select a breed to see it.</p>
          }
        </section>
      </div>
    </main>
  `,
  styles: `
    .wrap { max-width: 720px; margin: 2rem auto; padding: 0 1rem; font-family: system-ui, sans-serif; }
    input { display: block; width: 100%; padding: 0.5rem; margin: 0.25rem 0 1rem; box-sizing: border-box; }
    .layout { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }
    .list { list-style: none; margin: 0; padding: 0; max-height: 360px; overflow-y: auto; }
    button { width: 100%; text-align: left; padding: 0.4rem 0.6rem; border: 0; background: transparent; cursor: pointer; }
    button.active { background: #e8eefc; }
    figure { margin: 0; }
    img { background: #f4f1ea; border-radius: 8px; }
  `,
})
export class App {
  private readonly http = inject(HttpClient);

  protected readonly query = signal("");
  protected readonly selected = signal<Breed | null>(null);

  protected readonly catalog = toSignal(
    this.http.get<{ breeds: Breed[] }>(`${API}/api/v1/breeds`).pipe(
      map((data): CatalogState => ({ status: "ready", breeds: data.breeds })),
      catchError(() => of<CatalogState>({ status: "error", breeds: [] })),
    ),
    { initialValue: { status: "loading", breeds: [] } as CatalogState },
  );

  protected readonly matches = computed(() => {
    const q = this.query().trim().toLowerCase();
    const { breeds } = this.catalog();
    return q ? breeds.filter((b) => b.name.toLowerCase().includes(q)) : breeds;
  });

  protected previewUrl(slug: string): string {
    return `${API}/api/v1/images/${slug}/default/front-left?size=512`;
  }

  protected creditUrl(slug: string): string {
    return `${API}/dogs/${slug}`;
  }
}

src/app/app.html and src/app/app.css are no longer used. You can delete them, or leave them, as the component no longer references them.

Run it:

ng serve

Open http://localhost:4200, type "pug" and click the one result.

How it works

One request, one signal. toSignal subscribes to the HTTP observable when the component is created and unsubscribes when it is destroyed. The initialValue means the signal is never undefined, so the template needs no null checks. If the request fails, catchError turns the failure into a state the template can show.

Filtering is a computed. matches depends on query and catalog. It recalculates only when one of them changes, and the @for block tracks by slug so Angular reuses the DOM nodes of breeds that stay in the list.

The image URL is built, not stored. The catalog gives you slugs and names. The preview address is /api/v1/images/{slug}/default/front-left?size=512. It redirects to a transparent WebP, and the <img> follows the redirect. The cross-origin rule for images does not apply to display, so no extra Angular configuration is needed.

The credit link

Previews are free for any use, including commercial, when a visible link credits Dog Image API on the same page or next to the image. The caption link covers that, in the form each breed page gives you. The license has the details.

Next steps

  • Change front-left to head for a small avatar, or add a second signal for the current view and a row of buttons. The nine ids are listed in the docs.
  • Keep the key out of this app. Full-size PNG files need Authorization: Bearer <key> and belong on a server, as the Node.js download guide shows.
  • The same structure in other frameworks is in the Vue selector guide and the React picker guide.

Questions

Why use toSignal instead of subscribing in the component?

toSignal turns the HTTP result into a signal and unsubscribes for you when the component is destroyed. The template then reads the value the same way as any other signal.

Does this work without a key?

Yes. The breed list and the 512 pixel previews are keyless, and the API allows cross-origin reads from browser apps. Never put a key in Angular code, because it ships to every visitor.

Which Angular version does the code need?

It targets Angular 19 or newer, where components are standalone by default. The template uses the built-in @if and @for blocks.

Next steps