Skip to content
~/rifqi_abrory

Pages

Home
About
Stack
Experience
Projects
Writing
Contact
All projects

Projects

Avrist PINTAR — Agent Portal
Avrist e-Application
Avrist Academy — LMS CMS
Avrist e-Payment
PRUHub
SIYAP

Articles

Angular signals for everyday admin pages
Typed reactive forms in Angular, without the any
Consistent API errors in ASP.NET Core with ProblemDetails
Passing CancellationToken all the way down in ASP.NET Core
Graceful shutdown for Go HTTP services
Idempotency keys: making payment APIs safe to retry
A spam-resistant contact form with Next.js Server Actions
Offline-friendly forms in React Native: drafts and a submit queue
Request validation and error handling in Spring Boot 3
Keyset pagination in SQL Server: when OFFSET gets slow

Actions

Switch to dark mode
Read in Bahasa Indonesia
Copy email address
All articles

Angular signals for everyday admin pages

A practical guide to signal, computed, input, and effect, using a filterable list page as the example.

3 min read

On this page

Why signals for admin screens

Admin applications are mostly lists, filters, and forms. The state is simple, but it is everywhere: the current search text, the selected status, the page number, the rows from the API. Before signals, this state usually lived in a mix of class fields, BehaviorSubjects, and async pipes.

Signals give Angular one small, synchronous primitive for local state. This article builds a course list page with a search box and a status filter to show the four APIs you will use most.

signal: state you own

import { Component, signal } from "@angular/core";

type Status = "all" | "published" | "draft";

@Component({
  selector: "app-course-page",
  templateUrl: "./course-page.html",
})
export class CoursePage {
  readonly search = signal("");
  readonly status = signal<Status>("all");
}

Reading a signal is a function call, search(), and writing is set or update:

<input
  type="search"
  [value]="search()"
  (input)="search.set($any($event.target).value)"
  placeholder="Search courses"
/>

computed: derived state without subscriptions

The filtered list is not state of its own; it is derived from the rows and the filters. That is exactly what computed is for:

import { computed, inject } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";

export class CoursePage {
  private readonly api = inject(CourseApi);

  readonly search = signal("");
  readonly status = signal<Status>("all");

  // The HTTP observable becomes a signal; it starts as an empty list.
  readonly courses = toSignal(this.api.list(), { initialValue: [] });

  readonly visible = computed(() => {
    const term = this.search().trim().toLowerCase();
    const status = this.status();

    return this.courses().filter(
      (course) =>
        (status === "all" || course.status === status) &&
        course.title.toLowerCase().includes(term),
    );
  });

  readonly count = computed(() => this.visible().length);
}

visible recalculates only when search, status, or courses changes, and the result is cached in between. There is nothing to unsubscribe from.

The template uses the built-in control flow:

<p>{{ count() }} courses</p>

@for (course of visible(); track course.id) {
  <app-course-row [course]="course" />
} @empty {
  <p>No courses match your filters.</p>
}

input: signals from the parent

Child components receive data through input(), which is itself a signal:

import { Component, computed, input } from "@angular/core";

@Component({
  selector: "app-course-row",
  template: `
    <h3>{{ course().title }}</h3>
    <span [class.muted]="isDraft()">{{ course().status }}</span>
  `,
})
export class CourseRow {
  readonly course = input.required<Course>();
  readonly isDraft = computed(() => this.course().status === "draft");
}

Because inputs are signals, deriving values from them with computed replaces most uses of ngOnChanges.

effect: side effects only

effect runs when the signals it reads change. Use it for things that leave Angular's world, such as saving filters to localStorage or writing to the URL:

import { effect } from "@angular/core";

export class CoursePage {
  constructor() {
    effect(() => {
      localStorage.setItem("course-filters", JSON.stringify({ status: this.status() }));
    });
  }
}

A common mistake is using effect to copy one signal into another. If the value can be computed, use computed; it is simpler and avoids extra change-detection cycles.

Signals and RxJS together

Signals do not replace RxJS. HTTP calls, debouncing, and websockets are still streams. The bridge functions keep both worlds small:

  • toSignal(observable$) to read a stream in a template without async.
  • toObservable(signal) when you need operators such as debounceTime or switchMap.

For example, a debounced server-side search:

readonly results = toSignal(
  toObservable(this.search).pipe(
    debounceTime(300),
    distinctUntilChanged(),
    switchMap((term) => this.api.search(term)),
  ),
  { initialValue: [] },
);

Summary

  • signal for state the component owns.
  • computed for anything derived; it is cached and dependency-tracked.
  • input() for parent data, combined with computed instead of ngOnChanges.
  • effect only for side effects outside Angular.
  • Keep RxJS for async streams and connect it with toSignal and toObservable.