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-invalidng-pristine/ng-dirtyng-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--errords-input--touchedfield--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-invalidwhen invalidng-touchedafter blurng-dirtyafter 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--touchedappears - if invalid at that time:
field--invalidalso 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.
