Angular 21 introduced Signal Forms (experimental). They align forms with Angular’s signal-based reactivity model and generally aim for a cleaner, more explicit DOM.

One practical consequence: Signal Forms do not add the classic Angular CSS state classes by default.

That means you won’t automatically see classes like:

  • ng-valid / ng-invalid
  • ng-pristine / ng-dirty
  • ng-untouched / ng-touched

Why the config is needed

1) Backward compatibility (the big one)

Many applications—especially larger or older ones—have global or shared styles such as:

input.ng-invalid.ng-touched {
  border: 1px solid #e00;
}

When you migrate a form to Signal Forms and the classes are missing, your UI can “break” silently:

  • invalid inputs might no longer show red borders
  • touched/dirty styling disappears
  • error messages driven by CSS selectors stop working

Signal Forms Config lets you restore that behavior globally without rewriting templates everywhere.

2) Custom design systems (a modern use case)

Even if you don’t care about ng-* classes, you might want your own semantic classes that match your design system:

  • ds-input--error
  • ds-input--touched
  • field--dirty

Signal Forms Config lets you generate these classes reactively based on signal form state—centrally, consistently, and without template noise.


When to use it

Use provideSignalFormsConfig(...) when:

  • You are migrating from Reactive Forms and rely on .ng-invalid.ng-touched, etc.
  • You have shared/global CSS expecting Angular’s legacy state classes
  • You want to standardize styling across the app using your own class names
  • You want a “set it once” solution rather than repeating [class...] bindings on every input

You might skip it when:

  • your styling is fully component-driven (e.g., you only use explicit template bindings or wrapper components)
  • you prefer a minimal DOM and don’t need state classes at all

How it works (conceptually)

Signal Forms maintain state (valid/invalid/touched/dirty, etc.) as signals.
The config lets Angular map state ? CSS classes and apply them to form controls automatically.

Option A: Bring back the classic ng-* classes (migration-friendly)

Add a global provider (typically in app.config.ts for standalone apps):

import { ApplicationConfig } from '@angular/core';
import { provideSignalFormsConfig, NG_STATUS_CLASSES } from '@angular/forms';

export const appConfig: ApplicationConfig = {
  providers: [
    provideSignalFormsConfig({
      classes: NG_STATUS_CLASSES,
    }),
  ],
};
Result

Your Signal Form controls will again receive the familiar classes like:

  • ng-invalid when invalid
  • ng-touched after blur
  • ng-dirty after value changes

This is the fastest way to migrate without touching CSS.

Option B: Emit your own custom classes (design-system-friendly)

Instead of NG_STATUS_CLASSES, provide a class mapping. Each entry decides whether a class should be present based on the current state signal.

import { provideSignalFormsConfig } from '@angular/forms';

export const appConfig = {
  providers: [
    provideSignalFormsConfig({
      classes: {
        'ds-input--error': ({ state }) => state().invalid(),
        'ds-input--touched': ({ state }) => state().touched(),
        'ds-input--dirty': ({ state }) => state().dirty(),
        'ds-input--valid': ({ state }) => state().valid(),
      },
    }),
  ],
};
What you gain
  • Your HTML stays clean (no repeated [class...] bindings)
  • Styling rules become consistent across the entire app
  • You can align form states with your naming conventions

A detailed example (end-to-end)

1) Global config (custom classes)
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideSignalFormsConfig } from '@angular/forms';

export const appConfig: ApplicationConfig = {
  providers: [
    provideSignalFormsConfig({
      classes: {
        'field--invalid': ({ state }) => state().invalid(),
        'field--touched': ({ state }) => state().touched(),
      },
    }),
  ],
};
2) Component: create a Signal Forms control + bind it in the template (no manual class bindings)
// email.component.ts (conceptual)
import { Component } from '@angular/core';
// Signal Forms APIs are experimental; names can differ depending on Angular version/build.
import { signalControl, Validators } from '@angular/forms';

@Component({
  selector: 'app-email',
  templateUrl: './email.component.html',
})
export class EmailComponent {
  email = signalControl('', {
    validators: [Validators.required, Validators.email],
  });
}

You write your inputs normally. Angular applies classes automatically based on signal form state.

<!-- email.component.html (conceptual) -->
<label>
  Email
  <input type="email" [control]="email" />
</label>

3) CSS reacts to state
input.field--invalid.field--touched {
  border: 1px solid #d00;
  outline: none;
}

Now:

  • before the user interacts: no “touched” class ? no red border
  • after blur: field--touched appears
  • if invalid at that time: field--invalid also appears ? red border shows

All without adding template logic.


Why the API uses a config object (not many parameters)

provideSignalFormsConfig({ ... }) takes an object so Angular can add more options later (beyond CSS classes) without breaking the API. This matters because Signal Forms are experimental and likely to evolve.

By Shabazz

Software Engineer, MCSD, Web developer & Angular specialist , BizDevOps

Leave a Reply

Your email address will not be published. Required fields are marked *