angular-best-practices

star 0

Modern Angular v20+ best practices covering standalone components, signals, dependency injection, routing, forms, SSR, and testing. Use when building Angular applications or reviewing Angular code. Adapted from AnalogJS Angular Skills.

Sharjeelbaig By Sharjeelbaig schedule Updated 2/16/2026

name: angular-best-practices description: Modern Angular v20+ best practices covering standalone components, signals, dependency injection, routing, forms, SSR, and testing. Use when building Angular applications or reviewing Angular code. Adapted from AnalogJS Angular Skills.

Angular Best Practices Skill

Adapted from AnalogJS Angular Skills for Angular v20+.


1. Components (Standalone Architecture)

Always Use Standalone Components

Every component, directive, and pipe should be standalone: true (the default in Angular v20+). Never create NgModules for new code.

@Component({
  selector: 'app-user-card',
  standalone: true,
  imports: [DatePipe, NgOptimizedImage],
  template: `
    <div class="card">
      <img [ngSrc]="user().avatar" width="80" height="80" />
      <h3>{{ user().name }}</h3>
      <p>Joined {{ user().joinDate | date:'mediumDate' }}</p>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserCardComponent {
  user = input.required<User>();
}

Use Signal Inputs and Outputs

Replace @Input() and @Output() decorators with signal-based equivalents:

// ❌ Old decorator pattern
@Input() name: string = '';
@Output() clicked = new EventEmitter<void>();

// ✅ Signal-based (Angular v17.1+)
name = input<string>('');
name = input.required<string>();          // required input
clicked = output<void>();                 // output signal

Model Inputs for Two-Way Binding

// Component with two-way binding
value = model<string>('');    // creates input + output pair
value = model.required<string>();

// Usage
<app-input [(value)]="searchTerm" />

Host Bindings via host Property

@Component({
  host: {
    'role': 'button',
    '[attr.aria-pressed]': 'pressed()',
    '[class.active]': 'active()',
    '(click)': 'handleClick()',
    '(keydown.enter)': 'handleClick()',
  },
})

Always Use OnPush Change Detection

@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
})

OnPush works perfectly with signals — the component only re-renders when signal values change.

Content Projection

@Component({
  template: `
    <header>
      <ng-content select="[card-header]" />
    </header>
    <div class="body">
      <ng-content />
    </div>
    <footer>
      <ng-content select="[card-footer]" />
    </footer>
  `,
})
export class CardComponent {}

// Usage
<app-card>
  <h2 card-header>Title</h2>
  <p>Body content</p>
  <button card-footer>Action</button>
</app-card>

2. Signals

Signals are Angular's reactive primitive. Use them everywhere instead of RxJS for component state.

Core Signal Patterns

// Writable signal
count = signal(0);
count.set(5);
count.update(v => v + 1);

// Computed signal (derived, read-only)
doubleCount = computed(() => this.count() * 2);

// Linked signal (resets when source changes)
selectedItem = linkedSignal(() => this.items()[0]);

// Effect (side effects when signals change)
constructor() {
  effect(() => {
    console.log('Count changed:', this.count());
  });
}

Signal vs RxJS Decision

Use Case Use
Component state signal()
Derived values computed()
Reset on source change linkedSignal()
Side effects effect()
HTTP requests httpResource() or HttpClient
WebSocket streams RxJS
Complex async (debounce/merge) RxJS
Route params Signal-based route inputs

RxJS Interop

import { toSignal, toObservable } from '@angular/core/rxjs-interop';

// Observable → Signal
count$ = interval(1000);
count = toSignal(this.count$, { initialValue: 0 });

// Signal → Observable
searchTerm = signal('');
searchTerm$ = toObservable(this.searchTerm);

3. Dependency Injection

Use inject() Function

// ❌ Constructor injection
constructor(
  private userService: UserService,
  private router: Router,
) {}

// ✅ inject() function
private userService = inject(UserService);
private router = inject(Router);

Injection Tokens for Non-Class Values

const API_URL = new InjectionToken<string>('API_URL');

// Provide
bootstrapApplication(AppComponent, {
  providers: [
    { provide: API_URL, useValue: 'https://api.example.com' },
  ],
});

// Inject
private apiUrl = inject(API_URL);

4. Routing

Functional Routing Configuration

export const routes: Routes = [
  { path: '', component: HomeComponent },
  {
    path: 'dashboard',
    loadComponent: () => import('./dashboard.component').then(m => m.DashboardComponent),
    canActivate: [authGuard],
  },
  {
    path: 'admin',
    loadChildren: () => import('./admin/routes').then(m => m.ADMIN_ROUTES),
    canMatch: [adminGuard],
  },
];

Functional Guards

export const authGuard: CanActivateFn = (route, state) => {
  const auth = inject(AuthService);
  const router = inject(Router);

  if (auth.isAuthenticated()) return true;
  return router.createUrlTree(['/login'], {
    queryParams: { returnUrl: state.url },
  });
};

Signal-Based Route Parameters

@Component({...})
export class UserComponent {
  // Route param as signal input (Angular v17.1+)
  id = input.required<string>();

  user = computed(() => this.userService.getUser(this.id()));
}

// Enable in app config
provideRouter(routes, withComponentInputBinding())

5. HTTP & Data Fetching

httpResource() for Declarative Data Fetching

@Component({...})
export class UsersComponent {
  searchTerm = signal('');

  usersResource = httpResource<User[]>(() => ({
    url: '/api/users',
    params: { search: this.searchTerm() },
  }));

  users = this.usersResource.value;       // Signal<User[] | undefined>
  isLoading = this.usersResource.isLoading; // Signal<boolean>
  error = this.usersResource.error;        // Signal<Error | undefined>
}

resource() for Generic Async

userResource = resource({
  request: () => this.userId(),
  loader: async ({ request: id }) => {
    const res = await fetch(`/api/users/${id}`);
    return res.json();
  },
});

HttpClient for Complex Requests

private http = inject(HttpClient);

updateUser(id: string, data: Partial<User>) {
  return this.http.put<User>(`/api/users/${id}`, data).pipe(
    catchError(this.handleError),
  );
}

6. Forms (Signal Forms — Experimental)

Signal Forms is the new Angular forms API. For production, Reactive Forms is still valid.

Signal Forms Setup

import { SignalForm, FormField } from '@angular/forms/signal';

@Component({
  template: `
    <form (ngSubmit)="onSubmit()">
      <input [formField]="name" />
      @if (name.hasError('required')) {
        <span class="error">Name is required</span>
      }
      <button type="submit" [disabled]="!form.valid()">Save</button>
    </form>
  `,
})
export class UserFormComponent {
  form = new SignalForm({
    name: new FormField('', { validators: [Validators.required] }),
    email: new FormField('', { validators: [Validators.required, Validators.email] }),
  });

  name = this.form.controls.name;

  onSubmit() {
    if (this.form.valid()) {
      console.log(this.form.value());
    }
  }
}

Reactive Forms (Stable)

private fb = inject(FormBuilder);

form = this.fb.group({
  name: ['', [Validators.required, Validators.minLength(2)]],
  email: ['', [Validators.required, Validators.email]],
  address: this.fb.group({
    street: [''],
    city: ['', Validators.required],
  }),
});

7. SSR & Hydration

Setup

// app.config.server.ts
export const serverConfig: ApplicationConfig = {
  providers: [
    provideServerRendering(),
    provideServerRoutesConfig([
      { path: '**', renderMode: RenderMode.Server },
      { path: 'static/**', renderMode: RenderMode.Prerender },
      { path: 'api/**', renderMode: RenderMode.Server },
    ]),
  ],
};

Browser-Only Code

Never access window, document, or localStorage during SSR:

// ❌ Breaks SSR
ngOnInit() {
  const width = window.innerWidth;
}

// ✅ Use afterNextRender
constructor() {
  afterNextRender(() => {
    const width = window.innerWidth;
    this.setupResize();
  });
}

Incremental Hydration with @defer

<!-- Lazy hydrate on viewport visibility -->
@defer (hydrate on viewport) {
  <app-comments [postId]="post.id" />
} @placeholder {
  <div class="comments-skeleton">Loading comments...</div>
}

<!-- Hydrate on user interaction -->
@defer (hydrate on interaction) {
  <app-rich-editor />
} @placeholder {
  <textarea placeholder="Click to edit..."></textarea>
}

HTTP Caching for SSR

// Cache API responses during SSR
export const serverConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(withFetch()),
    {
      provide: HTTP_TRANSFER_CACHE_INTERCEPTOR_FN,
      useValue: (req: HttpRequest<unknown>) => {
        return !req.url.includes('/api/user'); // don't cache user-specific data
      },
    },
  ],
};

8. Directives

Attribute Directives

@Directive({
  selector: '[appHighlight]',
  standalone: true,
  host: {
    '(mouseenter)': 'onMouseEnter()',
    '(mouseleave)': 'onMouseLeave()',
    '[style.backgroundColor]': 'bgColor()',
  },
})
export class HighlightDirective {
  color = input('yellow', { alias: 'appHighlight' });
  bgColor = signal('');

  onMouseEnter() { this.bgColor.set(this.color()); }
  onMouseLeave() { this.bgColor.set(''); }
}

Host Directive Composition

@Component({
  hostDirectives: [
    { directive: TooltipDirective, inputs: ['tooltip'] },
    { directive: RippleDirective },
  ],
})
export class ButtonComponent {}

9. Testing (Vitest)

Setup

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import angular from '@analogjs/vitest-angular';

export default defineConfig({
  plugins: [angular()],
  test: {
    globals: true,
    environment: 'jsdom',
    include: ['src/**/*.spec.ts'],
  },
});

Component Tests

import { render, screen } from '@testing-library/angular';

it('should display user name', async () => {
  await render(UserCardComponent, {
    inputs: { user: { name: 'Alice', avatar: '/alice.png' } },
  });

  expect(screen.getByText('Alice')).toBeTruthy();
});

it('should emit click event', async () => {
  const onClick = vi.fn();
  await render(UserCardComponent, {
    inputs: { user: mockUser },
    on: { clicked: onClick },
  });

  screen.getByRole('button').click();
  expect(onClick).toHaveBeenCalled();
});

Signal Testing

it('should compute derived values', () => {
  TestBed.runInInjectionContext(() => {
    const count = signal(0);
    const doubled = computed(() => count() * 2);

    expect(doubled()).toBe(0);
    count.set(5);
    expect(doubled()).toBe(10);
  });
});

Service Testing with Mocks

it('should fetch users', async () => {
  const mockHttp = { get: vi.fn().mockReturnValue(of(mockUsers)) };

  TestBed.configureTestingModule({
    providers: [
      UserService,
      { provide: HttpClient, useValue: mockHttp },
    ],
  });

  const service = TestBed.inject(UserService);
  const users = await firstValueFrom(service.getUsers());
  expect(users).toEqual(mockUsers);
});

10. Tooling & CLI

Code Generation

ng generate component features/user-card --standalone
ng generate service core/auth
ng generate guard core/auth --functional
ng generate pipe shared/truncate --standalone

Build Configuration

// angular.json — production optimization
"configurations": {
  "production": {
    "budgets": [
      { "type": "initial", "maximumWarning": "500kB", "maximumError": "1MB" }
    ],
    "optimization": true,
    "outputHashing": "all",
    "sourceMap": false
  }
}

Quick Reference

Area Modern Pattern Avoid
Components standalone: true + signal inputs NgModules, @Input() decorator
State signal(), computed() Plain variables, BehaviorSubject for local state
DI inject() function Constructor injection
Routing Functional guards, loadComponent() Class-based guards, eager loading
HTTP httpResource(), resource() Manual subscribe/unsubscribe
Forms Signal Forms or Reactive Forms Template-driven forms
SSR afterNextRender(), @defer hydration Direct window/document access
Testing Vitest + Testing Library Karma, Jasmine
Change Detection OnPush Default change detection

Attribution

Based on AnalogJS Angular Skills for Angular v20+.

Install via CLI
npx skills add https://github.com/Sharjeelbaig/developer-ai --skill angular-best-practices
Repository Details
star Stars 0
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator
Sharjeelbaig
Sharjeelbaig Explore all skills →