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 withoutasync.toObservable(signal)when you need operators such asdebounceTimeorswitchMap.
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
signalfor state the component owns.computedfor anything derived; it is cached and dependency-tracked.input()for parent data, combined withcomputedinstead ofngOnChanges.effectonly for side effects outside Angular.- Keep RxJS for async streams and connect it with
toSignalandtoObservable.