Angular Standalone Patterns
Quick Guide: Components are standalone and declare their own
imports; NgModules are opt-in. State is signals —signal(),computed(),linkedSignal()for derived state you also write to,effect()only for genuine side effects andafterRenderEffect()for DOM work. Communication isinput(),output()andmodel(). Templates use@if,@for(always withtrack),@switchand@defer, none of which need an import. Dependencies come frominject().
Detailed Resources:
- examples/core.md — a complete standalone component, signals, control flow, parent-child communication
- examples/dependency-injection.md —
inject(),InjectionToken, injection options - examples/model.md —
model()two-way binding, and wheninput()+output()is the better fit - examples/defer.md — every
@defertrigger, with prefetch and placeholder timing - examples/angular-19-features.md —
linkedSignal(),resource(),rxResource(),afterRenderEffect()phases - examples/rxjs.md —
toSignal(),toObservable(), and which of the two a problem wants - reference.md — decision trees, anti-patterns with corrected code, API and syntax tables, component checklist
Which path applies
- Angular 19 —
standalone: trueis the default, so the flag is noise; writestandalone: falseonly for a component that genuinely belongs to an NgModule. - Angular 17–18 — the same patterns apply, but
standalone: trueis stated explicitly on every component, directive and pipe. - Signal APIs by version —
linkedSignal()andafterRenderEffect()land in 19,httpResource()in 19.2, and the resource family is still experimental. examples/angular-19-features.md marks each one.
Before writing Angular code
Declare inputs and outputs with input(), output() and model(). They are signals, so a computed() can depend on an input directly and no ngOnChanges is needed to notice it changed.
Take dependencies with inject() in a field initialiser. It works outside a constructor — in a function, a route guard, a factory — where constructor parameters cannot reach.
Write templates with @if, @for, @switch and @defer. They need no import, narrow types better than the structural directives, and @for has @empty and the $index/$first/$last context built in.
Give every @for a track expression. Without a stable key Angular rebuilds the rows rather than moving them, which loses focus and element state along with the performance.
Update a signal through .set() or .update() returning a new reference. Equality is Object.is, so mutating the array or object in place leaves the reference unchanged and nothing is notified.
Use linkedSignal() for derived state that is also writable. It recomputes from its source and still accepts a direct write, which is what a pair of signals kept in step by an effect() was imitating.
Auto-detection: Angular standalone component, signal, computed, effect, linkedSignal, resource, rxResource, httpResource, input(), output(), model(), @if, @for, @switch, @defer, inject(), InjectionToken, provideRouter, bootstrapApplication, afterRenderEffect, afterNextRender, DestroyRef, toSignal, toObservable, viewChild, viewChildren
Applies to:
- Standalone components, their
importsarray and their providers - Signal state:
signal,computed,linkedSignal,effect,afterRenderEffect - Component communication with
input,outputandmodel - Built-in control flow and
@deferlazy loading - Dependency injection with
inject()and injection tokens - Application bootstrap and standalone route configuration
- The resource API, and interop between signals and observables
Handled elsewhere:
- Styling — a component names its
stylesorstyleUrland settles nothing about what goes in them - Application-wide state stores layered above component signals
- Server-state caching and invalidation policy
- Test doubles for the network
Philosophy
Standalone removed the second declaration site. A component names what it uses in its own imports, so the dependency graph is readable from the component and a lazy route can point at a component rather than at a module wrapping one.
Signals then removed the second question. Change detection used to ask "what might have changed?" and walk the tree; a signal records who read it, so an update notifies exactly those consumers. That is why the guidance keeps pushing work down the chain: computed() where a value is derived, linkedSignal() where it is derived and writable, effect() only where something outside the graph has to happen — and afterRenderEffect() where that something is the DOM, because it runs in phases that keep reads and writes from thrashing layout.
Core patterns
Pattern 1: Standalone component
A component declares its own imports and communicates through signal functions.
@Component({
selector: "app-user-card",
imports: [DatePipe],
template: `
<h2>{{ user().name }}</h2>
<time>{{ user().createdAt | date: "mediumDate" }}</time>
<button (click)="edit.emit(user())">Edit</button>
`,
})
export class UserCardComponent {
user = input.required<User>();
edit = output<User>();
}
Full code: examples/core.md
Pattern 2: Signals
count = signal(0);
doubleCount = computed(() => this.count() * 2);
this.count.set(5);
this.count.update((value) => value + 1);
items = signal<Item[]>([]);
this.items.update((items) => [...items, newItem]);
computed() is memoised and lazy; a method with the same body recomputes on every template read. Reserve effect() for logging, analytics, storage and other work outside the signal graph.
Full code: examples/core.md
Pattern 3: linkedSignal for writable derived state
options = input.required<Option[]>();
selected = linkedSignal(() => this.options()[0]);
selected follows options and still accepts selected.set(...) from a click. Its computation form takes the previous value, which is how a selection survives a source change instead of resetting.
Full code: examples/angular-19-features.md
Pattern 4: Inputs, outputs and model
placeholder = input("Search...");
minLength = input.required<number>();
query = model("");
search = output<string>();
isValidSearch = computed(() => this.query().length >= this.minLength());
model() gives the parent [(query)]. Reach for it where the child genuinely owns the edit; input() plus output() keeps the flow one-way and is the better default.
Full code: examples/model.md
Pattern 5: Control flow
@switch (state()) { @case ("loading") {
<div>Loading…</div>
} @case ("error") { <button (click)="retry.emit()">Retry</button> } @case
("success") { @for (user of users(); track user.id; let i = $index) {
<li>{{ i + 1 }}. {{ user.name }}</li>
} @empty {
<li>No users found</li>
} } }
@if (user(); as user) binds the narrowed value, so the signal is called once rather than in every expression beneath it.
Full code: examples/core.md
Pattern 6: @defer
@defer (on viewport) {
<app-heavy-chart />
} @placeholder (minimum 200ms) {
<div class="chart-skeleton"></div>
} @loading (after 100ms; minimum 500ms) {
<div class="spinner"></div>
} @error {
<div>Failed to load chart</div>
}
@placeholder reserves the space, and the after/minimum timings on @loading are what stop a fast load flashing a spinner. Defer what is below the fold, behind an interaction, or conditional — never what is visible on arrival, which trades bundle size for LCP.
Full code: examples/defer.md
Pattern 7: Dependency injection
@Injectable({ providedIn: "root" })
export class UserService {
private http = inject(HttpClient);
private config = inject(CONFIG_TOKEN, { optional: true });
}
inject() also takes { skipSelf: true } to start at the parent injector and { self: true } to refuse to leave the current one. It must run in an injection context — a field initialiser or a constructor — never inside a method.
Full code: examples/dependency-injection.md
Pattern 8: Bootstrap and routes
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(
routes,
withComponentInputBinding(),
withPreloading(PreloadAllModules),
),
provideHttpClient(),
],
};
bootstrapApplication(AppComponent, appConfig);
export const routes: Routes = [
{
path: "users/:id",
loadComponent: () =>
import("./users/user-detail.component").then(
(m) => m.UserDetailComponent,
),
},
];
loadComponent lazy-loads a component with no wrapper module. withComponentInputBinding() binds route and query params straight to input() signals, so a route component needs no ActivatedRoute — a query param that may be absent is typed input<string | undefined>().
Pattern 9: Async data with resource()
userResource = resource({
params: () => ({ id: this.userId() }),
loader: async ({ params, abortSignal }) => {
const response = await fetch(`/api/users/${params.id}`, {
signal: abortSignal,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return (await response.json()) as User;
},
});
The resource re-runs when params changes and aborts the superseded request, so the signal-plus-effect combination that used to race is not needed. Guard reads with hasValue(), which narrows the type as well as the state. rxResource() takes an observable loader and httpResource() (19.2) goes through HttpClient and its interceptors.
Full code: examples/angular-19-features.md
Pattern 10: Lifecycle and DOM effects
private destroyRef = inject(DestroyRef);
private elementRef = inject(ElementRef);
width = signal(0);
constructor() {
afterNextRender(() => {
const observer = new ResizeObserver(([entry]) => this.width.set(entry.contentRect.width));
observer.observe(this.elementRef.nativeElement);
this.destroyRef.onDestroy(() => observer.disconnect());
});
}
| Legacy hook | Signal-era replacement |
|---|---|
ngOnInit |
field initialiser, or effect() |
ngOnChanges |
effect() reading the input() signal |
ngAfterViewInit |
afterNextRender() |
ngAfterViewChecked |
afterRender() (afterEveryRender() in 20) |
ngOnDestroy |
DestroyRef.onDestroy() |
| DOM side effects | afterRenderEffect() with explicit phases |
Full code: examples/angular-19-features.md
Pattern 11: Observable interop
users = toSignal(this.userService.getUsers(), { initialValue: [] });
count$ = toObservable(this.count);
toSignal() needs an initialValue for any source that has not emitted yet; without one the signal's type includes undefined and a template read before the first emission throws.
Full code: examples/rxjs.md
Red flags
Breaks at runtime:
- A signal's value mutated in place —
items().push(x)leaves the reference identical, soObject.isreports no change and nothing re-renders inject()called from a method — it needs an injection context, and throws outside onetoSignal()withoutinitialValueon a source that has not emitted — reads before the first emission failresource(),rxResource()orhttpResource()used for a write — all three are read-only; a POST, PUT or DELETE goes throughHttpClientresource.value()read without checkinghasValue()— the guard is what narrows away the loading and error states@forwithouttrack— Angular tears down and rebuilds each row, discarding focus, scroll position and animation state- A cleanup function returned from
effect()— the return value is ignored, so a timer or subscription opened there leaks on every re-run; teardown goes in theonCleanupcallback the effect body is handed as its argument
Surprising behaviour:
standalone: trueis the default from 19, so writing it is harmless noise while writing nothing is correct — the two look identical in review@deferrenders its@placeholderduring server rendering and ignores every trigger therelinkedSignal()resets to its computed value whenever the source changes; preserving a user's choice needs the computation form that receives the previous valueallowSignalWriteswas removed in 19 and writing a signal inside aneffect()is now allowed — which makes an effect-maintained derived value compile quietly where it used to complainafterRenderEffect()defaults to themixedReadWritephase, the one that thrashes layout; nameearlyReadandwriteinsteadsignal()compares withObject.is, so two structurally equal objects count as a change unless a customequalis supplied- Effects run during change detection from 19, not as microtasks, so ordering assumptions from earlier versions no longer hold