← All posts
·12 min read

Claude Code with Angular: Standalone, Signals, OnPush

Claude CodeAngularTypeScriptFrontend
Claude Code with Angular: standalone, signals, OnPush

Why Angular without CLAUDE.md generates legacy patterns

Modern Angular looks different from the Angular most training data describes. Standalone components have replaced NgModule as the default unit of composition. Signals have replaced manual Observable subscriptions for most component state. The inject() function has largely replaced constructor injection. OnPush change detection is the norm, not the exception. These are not minor stylistic shifts; they change how a component is structured from the first line. The problem for a code-generating model is that the legacy patterns dominate the corpus, so without explicit direction, Claude generates a component wrapped in an NgModule, subscribing to observables in ngOnInit, with default change detection, because that is the Angular it has seen the most.

A Claude Code session that writes Angular without project rules produces code that works but is a generation behind. It declares an @NgModule to register a component that should be standalone. It injects services through the constructor when inject() is cleaner and works in more contexts. It subscribes to an observable in ngOnInit and forgets to unsubscribe, leaking a subscription every time the component mounts. It uses untyped FormGroup so the form's shape is invisible to the type checker. It leaves change detection on the default strategy, so every component checks on every event regardless of whether its inputs changed. Each of these is functional and each is the pattern a modern Angular team has moved away from, which means a reviewer spends time modernizing code that should have been current from the start.

This guide covers the CLAUDE.md configuration that locks Claude Code into modern Angular: standalone components as the only kind, signals for component state, OnPush change detection everywhere, typed reactive forms, the takeUntilDestroyed operator for the subscriptions that remain, and permission hooks that keep the build and test commands sane. For the component testing layer, Claude Code with Vitest covers the unit test setup, and for the end-to-end layer, Claude Code with Playwright covers browser testing. For the TypeScript configuration that underpins all of it, Claude Code with TypeScript covers the compiler settings.

The Angular CLAUDE.md template

The CLAUDE.md at your project root is read at the start of every Claude Code session. For a modern Angular application it needs to declare the Angular version, the standalone-only policy, the signals-first rule, the change detection strategy, the forms approach, and the hard rules that block the legacy patterns Claude reaches for most often.

# Angular rules

## Stack
- Angular 18+ with standalone components (NO NgModules)
- Signals for component state, RxJS only for streams and async
- Vitest for unit tests, Playwright for e2e
- ESLint with angular-eslint, strict template type checking on

## Components (MANDATORY)
- ALL components are standalone, NEVER declared in an NgModule
- changeDetection: ChangeDetectionStrategy.OnPush on EVERY component
- inject() for dependency injection, NOT constructor injection
- Imports declared in the component's own imports array
- input() and output() signal APIs, NOT @Input()/@Output() decorators

## State (MANDATORY)
- Component state held in signals, NEVER plain class properties for view state
- Derived state via computed(), NEVER recomputed in the template
- Side effects via effect(), with explicit cleanup
- Services expose state as signals or as Observables, document which

## RxJS (MANDATORY)
- Subscriptions in components use takeUntilDestroyed(), NEVER manual unsubscribe
- Prefer the async pipe or toSignal() over manual subscribe in templates
- NEVER subscribe in ngOnInit without a teardown
- Operators imported from rxjs, NEVER from rxjs/internal

## Forms (MANDATORY)
- Reactive forms with typed FormGroup/FormControl, NEVER untyped
- NonNullableFormBuilder for controls that should never be null
- Validators declared with the control, custom validators are pure functions
- NEVER use template-driven forms for anything beyond a single input

## Templates
- Control flow with @if/@for/@switch, NOT *ngIf/*ngFor
- @for MUST declare track for list rendering
- No logic in templates beyond signal reads and computed values

## Hard rules
- NEVER declare a component in an NgModule
- NEVER omit OnPush change detection
- NEVER subscribe without takeUntilDestroyed or the async pipe
- NEVER use an untyped FormGroup
- NEVER use *ngIf/*ngFor, use the @if/@for control flow
- NEVER use constructor injection, use inject()
- ALWAYS declare track on @for
- ALWAYS hold view state in signals

Six rules here prevent the majority of legacy code Claude generates without them.

The standalone-only rule is the one that brings the architecture into the current generation. NgModule was the unit of composition for years, so Claude reaches for it by default. The rule bans it outright: every component declares its own dependencies in its imports array, and there is no module ceremony around it. This is not just style; standalone components are simpler to lazy-load, test, and reason about, and the rule makes them the only option.

The signals-first rule changes how component state is held. The legacy pattern is a plain class property mutated directly, or an observable subscribed in ngOnInit. The rule requires signals for view state, which gives Angular fine-grained reactivity and makes OnPush change detection work cleanly. Derived values use computed() so they recompute only when their inputs change, instead of being recalculated on every template pass.

The OnPush rule is the one that fixes performance by default. With the default change detection strategy, every component is checked on every event anywhere in the app, which is wasteful and gets worse as the tree grows. OnPush tells Angular to check a component only when its inputs change or a signal it reads updates. Combined with signals, this gives the framework precise knowledge of what changed, so it checks only what it must.

A standalone, signal-based component

A modern component is standalone, uses inject(), holds state in signals, and runs OnPush change detection.

// src/app/order-list/order-list.component.ts
import {
  ChangeDetectionStrategy,
  Component,
  computed,
  inject,
  signal,
} from "@angular/core";
import { CommonModule } from "@angular/common";
import { OrderService } from "../services/order.service";

@Component({
  selector: "app-order-list",
  standalone: true,
  imports: [CommonModule],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @if (loading()) {
      <p>Loading orders</p>
    } @else {
      <p>{{ count() }} orders, {{ totalLabel() }}</p>
      <ul>
        @for (order of orders(); track order.id) {
          <li>{{ order.id }}: {{ order.total | currency }}</li>
        } @empty {
          <li>No orders yet</li>
        }
      </ul>
    }
  `,
})
export class OrderListComponent {
  private readonly orderService = inject(OrderService);

  protected readonly orders = this.orderService.orders;
  protected readonly loading = this.orderService.loading;

  protected readonly count = computed(() => this.orders().length);
  protected readonly totalLabel = computed(() => {
    const sum = this.orders().reduce((acc, o) => acc + o.total, 0);
    return `total ${sum.toFixed(2)}`;
  });
}

Every modern rule shows up here. The component is standalone: true with its imports declared inline. The service is obtained with inject(), not the constructor. The view state comes from signals exposed by the service, and the derived count and totalLabel are computed() so they recompute only when orders() changes. The template uses @if, @for with a track expression, and @empty for the empty case, which is the current control flow syntax. The changeDetection: OnPush means Angular checks this component only when one of its signals updates, which is the efficient default the CLAUDE.md rule mandates.

Signal inputs and outputs

The decorator-based @Input() and @Output() have signal-based equivalents that integrate with the reactivity system.

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

@Component({
  selector: "app-order-card",
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <article (click)="selected.emit(order().id)">
      <h3>{{ order().id }}</h3>
      <p>{{ statusLabel() }}</p>
    </article>
  `,
})
export class OrderCardComponent {
  readonly order = input.required<Order>();
  readonly highlight = input(false);
  readonly selected = output<string>();

  readonly statusLabel = computed(() =>
    this.order().total > 100 ? "priority" : "standard",
  );
}

The input.required<Order>() declares a required input as a signal, so reading order() in the template or a computed is reactive and the type checker enforces that callers provide it. The input(false) declares an optional input with a default. The output<string>() replaces @Output() EventEmitter. Because the input is a signal, the statusLabel computed recomputes automatically when the input changes, and OnPush change detection picks it up without any manual intervention. This is the signal-based component API the CLAUDE.md rule requires, and it removes the decorator boilerplate the legacy pattern carries.

Services that expose signals

A service holds state as signals and exposes read-only views, so components consume reactive state without being able to mutate it directly.

// src/app/services/order.service.ts
import { Injectable, inject, signal } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";

@Injectable({ providedIn: "root" })
export class OrderService {
  private readonly http = inject(HttpClient);

  private readonly _orders = signal<Order[]>([]);
  private readonly _loading = signal(false);

  readonly orders = this._orders.asReadonly();
  readonly loading = this._loading.asReadonly();

  load(): void {
    this._loading.set(true);
    this.http
      .get<Order[]>("/api/orders")
      .pipe(takeUntilDestroyed())
      .subscribe({
        next: (orders) => {
          this._orders.set(orders);
          this._loading.set(false);
        },
        error: () => this._loading.set(false),
      });
  }
}

The service keeps its writable signals private (_orders, _loading) and exposes asReadonly() views, so a component can read the state but only the service can change it, which keeps the source of truth in one place. The load method subscribes to the HTTP call with takeUntilDestroyed(), which ties the subscription's lifetime to the injection context and tears it down automatically, so there is no manual unsubscribe to forget. This single operator is what the CLAUDE.md RxJS rule mandates, and it eliminates the entire class of subscription leaks that the legacy ngOnInit-subscribe pattern is prone to. For the HTTP client configuration and interceptors, Claude Code with TypeScript covers the typed request and response shapes.

Typed reactive forms

Untyped forms hide the form's shape from the type checker. The CLAUDE.md rule requires typed FormGroup with NonNullableFormBuilder for controls that should never be null.

import { Component, inject } from "@angular/core";
import {
  NonNullableFormBuilder,
  ReactiveFormsModule,
  Validators,
} from "@angular/forms";

@Component({
  selector: "app-order-form",
  standalone: true,
  imports: [ReactiveFormsModule],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <form [formGroup]="form" (ngSubmit)="submit()">
      <input formControlName="reference" />
      <input formControlName="amount" type="number" />
      <button [disabled]="form.invalid">Save</button>
    </form>
  `,
})
export class OrderFormComponent {
  private readonly fb = inject(NonNullableFormBuilder);

  protected readonly form = this.fb.group({
    reference: this.fb.control("", [Validators.required, Validators.minLength(3)]),
    amount: this.fb.control(0, [Validators.required, Validators.min(0.01)]),
  });

  submit(): void {
    if (this.form.invalid) return;
    const value = this.form.getRawValue();
    // value is typed: { reference: string; amount: number }
  }
}

The NonNullableFormBuilder produces controls that are never null, so getRawValue() returns a fully typed object rather than one where every field might be null. The form's type is inferred from the group definition, which means value.reference is known to be a string and value.amount a number without any manual typing. The validators are declared alongside each control, so the form's rules live with its shape. This is the typed-forms discipline the CLAUDE.md rule enforces, and it turns the form from an untyped bag of values into a checked contract.

Routing with lazy-loaded standalone components

Standalone components make lazy loading simpler because there is no module to load. The route loads the component directly.

// src/app/app.routes.ts
import { Routes } from "@angular/router";

export const routes: Routes = [
  {
    path: "orders",
    loadComponent: () =>
      import("./order-list/order-list.component").then(
        (m) => m.OrderListComponent,
      ),
  },
  {
    path: "orders/:id",
    loadComponent: () =>
      import("./order-detail/order-detail.component").then(
        (m) => m.OrderDetailComponent,
      ),
    resolve: {
      order: () => inject(OrderService).loadOne(),
    },
  },
];

The loadComponent lazy-loads a standalone component on navigation, splitting it into its own bundle without the loadChildren and feature-module ceremony the legacy pattern required. The resolve runs a data load before the route activates, using inject() in the resolver function, which is the functional resolver API that replaced the class-based Resolve interface. This routing setup is the natural consequence of the standalone-only rule: with no modules, the router works directly with components, which makes the lazy-loading boundaries explicit and the bundles smaller.

Permission hooks for Angular development

The Angular CLI has commands that can overwrite files or run long-lived processes. Permission hooks in .claude/settings.local.json keep development fast and safe.

{
  "permissions": {
    "allow": [
      "Bash(ng build*)",
      "Bash(ng test --watch=false*)",
      "Bash(ng lint*)",
      "Bash(npx vitest run*)",
      "Bash(npx playwright test*)"
    ],
    "deny": [
      "Bash(ng serve*)",
      "Bash(ng generate*--force*)",
      "Bash(ng update*)",
      "Bash(ng add*)"
    ]
  }
}

The allow list permits the build, the test runs in non-watch mode, linting, and the test runners Claude needs to verify a change. The deny list catches the commands that should be deliberate: ng serve starts a long-lived dev server that blocks the session, ng generate --force overwrites existing files, and ng update and ng add modify dependencies and configuration in ways that need human review. These belong in the developer's hands, not in an autonomous loop. Combined with the Claude Code hooks system for project-wide policy, the result is a setup where Claude can build, test, and lint without starting a blocking server or rewriting files.

Testing standalone components

Standalone components are simpler to test because there is no module to configure. The test renders the component with its inputs and asserts on the rendered output.

// src/app/order-card/order-card.component.spec.ts
import { TestBed } from "@angular/core/testing";
import { OrderCardComponent } from "./order-card.component";

test("shows priority label for high-value orders", () => {
  const fixture = TestBed.createComponent(OrderCardComponent);
  fixture.componentRef.setInput("order", { id: "a", total: 150 });
  fixture.detectChanges();

  const text = fixture.nativeElement.textContent;
  expect(text).toContain("priority");
});

test("emits selected id on click", () => {
  const fixture = TestBed.createComponent(OrderCardComponent);
  fixture.componentRef.setInput("order", { id: "a", total: 50 });

  let emitted: string | undefined;
  fixture.componentInstance.selected.subscribe((id) => (emitted = id));
  fixture.detectChanges();

  fixture.nativeElement.querySelector("article").click();
  expect(emitted).toBe("a");
});

The test creates the standalone component directly with TestBed.createComponent, with no TestBed.configureTestingModule declaring a module, because the component carries its own imports. The setInput API sets the signal input the way a parent would, and detectChanges runs change detection so the computed values update. The first test asserts the rendered label, the second asserts the output emits on click. This is the test shape that standalone components and signal inputs make possible, and it is far less ceremony than the module-based testing the legacy pattern required. For the full test runner configuration, Claude Code with Vitest covers the setup, and for end-to-end flows, Claude Code with Playwright covers the browser tests.

Building Angular that is a generation current, not a generation behind

The Angular CLAUDE.md in this guide produces an application where every component is standalone with OnPush change detection, view state lives in signals with computed derivations, services expose read-only signal state, subscriptions tear down automatically, forms are typed, and the template uses the current control flow syntax. The result is Angular that matches how a modern team writes it, instead of the NgModule-era code a model generates by default.

The underlying principle is the same as any framework used with Claude Code. Angular without a CLAUDE.md produces working code that is a generation behind, because the legacy patterns dominate the training corpus and are the readable default a model reaches for. The CLAUDE.md is what makes the standalone, signal-based, OnPush patterns the default for Claude.

For the unit testing of components and services, Claude Code with Vitest covers the runner and mocking. For end-to-end browser flows, Claude Code with Playwright covers the test patterns. For the TypeScript compiler configuration that makes the typed forms and signal inputs work, Claude Code with TypeScript covers the settings.

Get Claudify. Build Angular components that are standalone, signal-based, and OnPush from the first line.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir Claudify - Featured on Startup Fame