Angular's move toward signals has forced many experienced developers to confront an uncomfortable realization: much of what we have historically described as “reactive” UI design was never truly state-driven. It was event-driven, stream-oriented, and often incidental in how state emerged as a side effect of emissions rather than as a first-class concept.
Nowhere is this more visible than in forms.
Forms sit at the intersection of user intent, validation rules, business constraints, asynchronous workflows, and UI feedback. They are simultaneously simple and deeply complex. For years, Angular developers learned to manage that complexity through reactive forms, a system that, while powerful, encouraged an event-centric mental model built around subscriptions, value streams, and implicit propagation.
Signals change the shape of that conversation.
They do not introduce a new way to listen for changes. They introduce a different way to describe reality. Instead of asking “what happened?” signals ask, “what is true right now?” That shift seems subtle until you attempt to design a form system around it. Then the implications become unavoidable.
This article is about those implications.
It is not a guide to migrating existing forms. It is not a replacement for Angular's official documentation. And it is not an argument that reactive forms are obsolete. Instead, it is an attempt to answer a more precise question: if you were to design a form state from first principles using signals, what shape should that state take, how should behaviour be expressed, and how do you avoid recreating the abstractions that signals were meant to replace?
To answer that, we need to unlearn some habits.
Note: Angular 21 introduced the first official Signals-based approach to forms. At the time of writing, this API is still experimental, meaning the Angular team is actively iterating on its design. Despite that status, it already offers a compelling glimpse into a future where Angular applications are built around state-driven reactivity rather than event-driven change detection.
Why Forms Expose the Limits of Event-driven Thinking
Event-driven systems excel at describing sequences. A click happens. A value changes. A request completes. Something reacts. That model works well when time and order matter more than the current truth.
Forms are different.
At any moment, a form is simply a snapshot. It has a value. It has validity. It has errors. It has fields that are enabled, disabled, dirty, pristine, touched, or untouched. These facts do not inherently exist in time; they exist simultaneously. Treating them as events forces developers to reconstruct the current state by replaying or aggregating past emissions.
Reactive forms solved this by centralizing state inside an opaque control tree. Developers were shielded from most of the mechanics, but the mental model remained stream oriented. We subscribed to valueChanges, reacted to statusChanges, and wrote logic that responded to transitions rather than expressing invariants.
Signals invert that relationship.
A signal is not something you listen to. It is something you read. It always represents the current value. When it changes, anything that depends on it updates automatically, but that propagation is an implementation detail, not the core abstraction.
Once you accept that, a form stops being a collection of listeners and starts being a structured state object with rules.
That framing unlocks clarity, but only if you resist the temptation to rebuild the old system using new primitives.
The Most Common Failure Mode: “Reactive Forms, but with Signals”
Early signal-based form experiments often follow a predictable pattern. Developers wrap each input in a writable signal. They add computed signals for validity. They wire effects to simulate valueChanges. Slowly, a familiar structure emerges: controls, groups, status flags, and propagation logic.
At that point, something has gone wrong.
The problem is not that these solutions are incorrect. Many of them work. The problem is that they reproduce complexity without reaping the benefits of the new model. The resulting code is often harder to reason about than both classic reactive
forms and true signal-first designs.
The warning sign is conceptual rather than syntactic. If you find yourself asking where to emit, how to subscribe, or how to pipe form behaviour, you are still thinking in terms of events. Signals do not eliminate those concepts entirely, but they push them to the boundaries. Forms, in particular, benefit from keeping those boundaries extremely narrow.
The question shifts from “how do changes flow?” to “what state do we want to model, and what should always be true about it?”
Reframing Form Design as State Modelling
A signal-first form begins with a deceptively simple decision: what is the authoritative representation of formData?
In many legacy systems, that answer is ambiguous. There is the form's internal value, the component's local state, the API payload shape, and sometimes a separate domain model. Synchronization logic grows organically between them, often hidden inside subscriptions or lifecycle hooks.
Signals encourage consolidation.
A form model should represent the data as it exists right now, in a shape that is meaningful to the application. That does not necessarily mean it matches the API contract perfectly, nor does it mean it mirrors the UI exactly. It means the state is intentional in this shape.
This model becomes the single source of truth. Inputs update it. Validation derives from it. UI state reflects it. Submission reads from it.
Once you commit to that principle, many secondary decisions become easier. You no longer need to ask how to propagate validity, because validity is simply a derived fact of the model. You no longer need to synchronize error messages because errors are data. You no longer need to listen for changes because anything that needs the state can read it.
The form stops being an event processor and becomes a state container.
Separating Domain Truth from Form Reality
One of the most subtle but important design decisions in signal-first forms is acknowledging that the data a user is editing is not always the same as the data your system ultimately cares about.
Consider a simple example: a registration form. The domain model might consist of a username, an email address, and a password hash. The form, however, includes raw passwords, confirmation fields, temporary validation flags, and UI affordances like “show password.”
Trying to force the form state to exactly mirror the domain model introduces friction. Conversely, allowing the form to sprawl into an unstructured blob of UI state leads to chaos.
The solution is not a new abstraction layer; it is a clear boundary.
The form model should be a consciously designed structure that represents editable intent. It can include transient fields, partial values, and UI-only state, but it should still feel coherent. More importantly, there should be a well-defined transformation from form model to domain model, typically applied at submission time.
Signals make this transformation explicit. Because the form state is always available synchronously, mapping it to a domain object becomes a pure function rather than a procedural workflow. That clarity pays dividends when forms become large, dynamic, or asynchronous.
Validation as a Property, Not a Process
Validation is where many signal-first designs either shine or collapse.
In event-driven systems, validation often feels procedural. A value changes. A validator runs. Errors are set. Status propagates upward. Developers reason about the order of operations, especially when asynchronous validation enters the picture.
Signals encourage a different view. Validation is not something that happens; it is something that is true or false given the current state.
A required field is invalid not because a validator ran, but because the value is empty. A password is weak not because an event fired, but because the current value does not satisfy a rule. Seen this way, validation logic becomes declarative.
This is why Angular's Signal Forms API exposes validation results as signals rather than callbacks. The framework itself is acknowledging that validation is a derived state.
Designing around that insight simplifies both mental and code complexity. Instead of asking when to validate, you ask what the rules are. Instead of orchestrating execution, you express constraints.
The challenge, of course, is handling asynchronous validation and side effects. But even there, the distinction holds: the result of validation is state, even if the computation involves time. Keeping that distinction clear prevents effects from leaking into places where they do not belong.
Effects Belong at the Edges, Not the Core
One of the most common mistakes in early signal-based codebases is overusing effects. Because effects feel powerful, they are often used as a replacement for subscriptions, lifecycle hooks, and even computed state.
In form design, this is particularly dangerous.
An effect that reacts to every keystroke can quickly become an implicit event handler, reintroducing the very coupling Signals were meant to remove. Effects that synchronize the state between signals often indicate that the model itself is fragmented.
A signal-first form keeps effects at the boundaries: autosave, debounced, server validation, analytics, or synchronization with external systems. The core form logic, values, errors, and enabled state—should be expressible without effects.
This constraint forces better modelling. If something cannot be expressed as a derived state, it is worth questioning whether it truly belongs inside the form or whether it is an interaction concern.
UI as a Projection of the State
One of the most satisfying aspects of a well-designed signal-first form is how little logic ends up in the template.
When the state is modelled explicitly, the UI becomes a projection. Inputs bind to signals. Error messages display the derived state. Sections appear or disappear based on computed conditions. There is no need for imperative show-hide logic or manual synchronization.
This does not mean templates become trivial. Complex forms still involve conditional rendering, repeated sections, and dynamic behaviour. But the logic driving those decisions lives in the model layer, where it can be tested, reasoned about, and evolved independently.
Signals align naturally with Angular's newer control-flow syntax, reinforcing this separation. The template describes what should be shown when certain conditions are true, not how to manage those conditions.
The Historical Accident of Form Abstractions
To understand why signal-first form design feels unfamiliar, it helps to acknowledge that most frontend form abstractions were not designed from first principles. They evolved incrementally, shaped by the constraints of early frameworks, DOM APIs, and performance limitations that no longer exist.
In the early days of web development, forms were little more than HTML elements with submit handlers. Validation happened on the server. State lived in the DOM. JavaScript's role was largely cosmetic. As applications became more interactive, client-side validation emerged, but it was bolted onto an event model that treated user input as a series of discrete actions rather than as a continuously evolving state.
Frameworks like AngularJS and later Angular inherited that lineage. Reactive forms, despite their name, were not built around state snapshots but around observable streams. They excelled at coordinating asynchronous behaviour, composing validation logic, and handling complex dynamic forms, but they also encoded assumptions that felt natural at the time: changes propagate through emissions, consumers react to those emissions, and consistency emerges through orchestration.
Signals challenge those assumptions not by criticizing them, but by making a different tradeoff explicit. They prioritize coherence over coordination. Instead of asking how to synchronize multiple moving parts, they ask whether those parts should exist separately at all.
Forms expose this tension more clearly than almost any other UI concern because they sit at the boundary between human intent and system constraints. A user does not think in terms of emissions or streams. They think in terms of what they have entered, what is missing, and what is wrong. A signal-first form attempts to model that mental reality directly.
Why “Current Truth” Beats “Change History”
One of the most underappreciated aspects of signal-based design is that it eliminates the need to reason about history in most day-to-day UI logic. In observable-driven systems, developers often carry an implicit mental timeline: what was the value before, what triggered this update, what order did things happen in?
Forms do not benefit from that framing.
When rendering an error message, the question is not how the form arrived at an invalid state. The question is simply whether the current value violates a rule. When enabling or disabling a submit button, the system does not care about the sequence of changes that led to the current state. It only cares whether submission is allowed now.
Signals make this explicit. There is no subscription context. There is no replay. There is only the current value and whatever is derived from it.
This has profound consequences for how form logic is structured. Instead of writing defensive code to handle partial updates, race conditions, or missed emissions, you describe invariants. If the invariants hold, the UI is correct. If they do not, the model is wrong, not the timing.
That shift dramatically reduces the surface area for bugs, especially in large forms with interdependent fields.
The Illusion of “Simple Forms” and Why it Breaks Down
Many developers assume that signal-first design is best suited for simple forms and that complexity inevitably pushes them back toward heavier abstractions. In practice, the opposite is often true.
Simple forms can be handled with almost any approach. Complexity is where architectural choices reveal their strengths and weaknesses.
Consider a form with conditional sections, cross-field validation, asynchronous checks, partial saves, and feature-flag-driven behaviour. In an event-driven model, complexity grows combinatorially. Each new condition introduces new subscriptions, new ordering concerns, and new edge cases where the state becomes temporarily inconsistent.
In a signal-first model, complexity grows linearly with the number of rules. Each rule is a pure expression of the current state. Each derived value has a single source of truth. The form does not become simpler, but it becomes predictable.
Predictability is the currency of maintainability.
Rethinking “Dirty,” “Touched,” and Other Legacy Concepts
Traditional form abstractions expose a rich set of status flags: dirty, pristine, touched, untouched, pending, valid, and invalid. These flags exist because the framework must infer intent from events. Did the user interact? Did the value change? Did validation run?
Signals offer an opportunity to re-evaluate which of these concepts are intrinsic and which are artifacts of the underlying model.
Some status indicators remain useful. Others become redundant or trivial to derive. A form is dirty if its current value differs from its initial value. That is not an event; it is a comparison. A field is touched if the user has interacted with it in a meaningful way, but even that can often be modelled explicitly rather than inferred.
This does not mean that signal-first forms eliminate the need for such metadata. It means that metadata becomes intentional rather than incidental. If your application needs to distinguish between “never interacted with” and “interacted but unchanged,” you can model that directly. If it does not, you do not inherit the complexity by default.
Angular's Signal Forms API reflects this philosophy by exposing state in a more granular and inspectable way. It does not assume which metadata matters; it makes the state observable and lets the application decide how to interpret it.
State Ownership and the End of Implicit Coupling
One of the most subtle benefits of signal-first form design is how clearly it forces you to answer the question of ownership.
In many existing applications, form state is scattered. Some state lives in the form object, some in component properties, some in services, and some implicitly in subscriptions. Changes ripple outward in ways that are difficult to trace.
Signals collapse that ambiguity. State lives where it is declared. Dependencies are explicit. If a component depends on form validity, it reads it. If a service needs the form's current value, it receives it. There is no hidden synchronization.
This clarity becomes especially important when forms outgrow a single component. Multi-step wizards, embedded editors, and collaborative forms all benefit from having a clear, shareable state model that does not depend on lifecycle timing or subscription order.
Signal-first forms scale not because they are smaller, but because they are sharable.
Forms as Part of Application State, Not an Island
Historically, forms have often been treated as special. They come with their own APIs, their own mental models, and their own lifecycle rules. This separation made sense when form abstractions were fundamentally different from the rest of the application.
Signals blur that boundary.
A form modelled with signals is just a state. It can live in a service. It can be composed with other states. It can participate in larger workflows without adapters or bridges. Validation state can influence navigation. Submission state can drive global loading indicators. Feature flags can alter form behaviour declaratively.
This unification is not merely convenient. It is architecturally significant. It means that forms no longer require special reasoning. They follow the same rules as the rest of the system.
When that happens, forms stop being a source of incidental complexity and start being an ordinary, well-behaved part of application design.
Why Resisting Abstraction Is a Form of Discipline
There is a temptation, especially among experienced developers, to immediately encapsulate patterns into reusable abstractions. With signal-first forms, that instinct must be tempered with discipline.
The framework is still evolving. Best practices are still emerging. Over-abstracting too early risks baking in assumptions that will not survive the next iteration of the API.
This is why the design principles outlined in this part avoid prescribing a custom form framework. The goal is not to replace Reactive Forms with Signal Forms v2. The goal is to understand what problems need solving and which ones disappear when the state is modelled correctly.
Before We Reach for the API
It is tempting to dive directly into Angular's Signal Forms primitives and begin wiring things together. After all, APIs are tangible. They offer a sense of progress. You can see results quickly.
But APIs are also the least stable part of this shift.
Names will change. Capabilities will expand. Some primitives may disappear entirely as Signal Forms evolve. What should not change is the way you think about form state. If the underlying design is sound, the surface area can move without forcing you to rethink your architecture.
This is where many early Signal Forms experiments falter. They start with familiar shapes—controls, groups, subscriptions—because those shapes feel safe. But familiarity is not the same as correctness. Recreating reactive forms with signals may work today, but it anchors your design to assumptions signals were explicitly introduced to challenge.
That is why this series starts here, without code. Not to delay implementation, but to ensure that implementation begins from a place of clarity rather than habit.
Designing for Truth, Not Traffic
At its core, signal-first form design is not about optimizing how changes move through a system. It is about being honest about what the system is at any given moment.
Forms were never event pipelines. They are snapshots of intent, constrained by rules, shaped by business requirements, and interpreted in the present tense. When we treat them as streams of events, we spend our time coordinating transitions instead of expressing truth.
Signals remove that escape hatch.
They force us to model the current state explicitly. They reward derived truth over procedural orchestration. They make ownership visible and side effects uncomfortable unless they are clearly justified.
Once you adopt that posture, many long-standing problems quietly lose their urgency. Validation stops being something you “run” and becomes something that either holds or does not. UI state stops being synchronized and becomes projected. Complexity does not disappear, but it becomes legible.
Validation as Derived State with Angular Signal Forms
So far, we have established the premise that signal-first form design is not about finding a newer way to “react” to value changes. It is about modelling form behaviour as explicit state and explicit relationships, rather than stitching behaviour together from event timing and control lifecycle.
That framing matters because validation is where form architectures usually collapse. Validation is not just a set of rules. In real applications, it becomes a coordination problem: field-level constraints, cross-field dependencies, form-level readiness, async checks, and user-facing visibility rules all start competing for space. In older models, those concerns often end up distributed across validators, status flags, and template conditionals, making them difficult to keep consistent over time.
Angular's Signal Forms API gives us a different baseline. Instead of building validation around a control graph, we build around a typed model signal and a field tree derived from that model. Each field exposes a FieldState object that provides reactive signals describing value, validation status, interaction state, and availability.
The focus is on validation specifically, but we cannot start with validation in the abstract. We need a small, concrete form built with the real Signal Forms primitives so that every validation pattern we introduce later has a consistent place to live.
So, this next section does one thing: it establishes a minimal Signal Forms foundation using the official API, model signal, field tree, and field state—without yet defining any validation rules. The moment that foundation is in place, we can layer validation as derived state: field-level, cross-field, and form-level, with clean separation between business rules and UX visibility.
Minimal Signal Forms Foundation: Model Signal, FieldTree, FieldState
Signal Forms start from a simple idea: your form data is a normal TypeScript model held in a signal, and form() turns that model into a FieldTree whose shape mirrors the model. Fields are accessed via dot notation, and the [formField] directive binds inputs directly to those fields with automatic synchronization.
We'll use a registration-style model (Listing 1) because it naturally supports the validation topics we'll cover later (required fields, format checks, password confirmation, and eventually async checks). For now, we are deliberately not defining any validation rules yet.
Listing 1: Registration form
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { FormField, form } from '@angular/forms/signals';
interface RegistrationModel {
email: string;
password: string;
confirmPassword: string;
}
@Component({
selector: 'app-registration',
standalone: true,
imports: [FormField],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<form>
<label>
Email
<input type="email" [formField]="registrationForm.email" />
</label>
<label>
Password
<input type="password" [formField]="registrationForm.password" />
</label>
<label>
Confirm password
<input type="password" [formField]="registrationForm.confirmPassword" />
</label>
</form>
`,
})
export class RegistrationComponent {
// 1) The model is just a signal holding your form data
registrationModel = signal<RegistrationModel>({
email: '',
password: '',
confirmPassword: '',
});
// 2) form(model) produces a FieldTree mirroring the model shape
registrationForm = form(this.registrationModel);
}
This is already a working Signal Form: typing in any input updates the model signal automatically and reading the model signal reflects the current state. The important thing, though, is what we can do next—because Signal Forms do not stop at values.
Each node in the FieldTree can be “called” as a function to obtain its FieldState object. That FieldState exposes signals for value, validation status, interaction tracking, and availability. In other words, the form isn't just data; it is a structured state system where each field carries the state we typically try to reconstruct manually in event-driven approaches.
For example, this is how you access the currentValueSignal for a field:
// current email string
const email = this.registrationForm.email().value();
And this is how you access interaction and validation-related state (we'll make these meaningful once validation rules are introduced):
const emailState = this.registrationForm.email();
// has the user interacted with it?
emailState().touched();
// has it changed from the initial value?
emailState().dirty();
// does it currently have validation errors?
emailState().invalid();
// structured validation errors
emailState().errors();
// async validators still running?
emailState().pending();
Two details matter here:
First, Signal Forms separates field access from field state. The FieldTree node (registrationForm.email) is what you bind to the template and treat as “the field.” The FieldState (registrationForm.email()) is the state snapshot, exposed as signals, that you use to make decisions in the UI.
Second, validity is not a single Boolean in the simplistic sense. Signal Forms distinguishes valid() from “not invalid,” and also exposes pending() because asynchronous validation can make a field neither valid nor invalid yet. That nuance becomes central once we introduce async rules; these rules prevent a common class of “form is valid while validation is still running” mistakes.
With this foundation in place, we can now do the real work: defining validation rules in the schema function, reading errors from FieldState, composing cross-field constraints, and separating business validation from UX visibility rules.
Field-Level Validation as Derived State
With the form foundation in place, the key move in Signal Forms is that validation is not “attached” to controls the way legacy forms do it. You don't create a control, then configure validators on it, then hope the right status propagates up the tree. Instead, you define validation rules in one place—inside the schema function passed to form()—and those rules bind to the form's fields via the schema path tree.
That design choice matters because it changes the failure mode. In event-driven forms, validation often becomes distributed: some rules live in validators, some live in custom group validators, some leak into templates, and some get enforced again during submit “just in case.” In Signal Forms, the schema becomes the single authoritative description of field constraints, and field state becomes the authoritative way to read outcomes.
The schema function runs once at initialization, and the rules you register there execute automatically whenever the relevant field values change. This is where “derived state” becomes concrete: the validity signals you read later (valid(), invalid(), errors(), pending()) are always downstream of the current value plus the rules bound in the schema—no imperative revalidation step needed.
Let's extend the minimal registration model (Listing 2) by adding field-level constraints. We'll start with the ones that are both universal and illustrative: required fields and basic format checks.
Listing 2: Registration form extended
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { email, form, FormField, required } from '@angular/forms/signals';
interface RegistrationModel {
email: string;
password: string;
confirmPassword: string;
}
@Component({
selector: 'app-registration',
standalone: true,
imports: [FormField],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './registration.component.html',
})
export class RegistrationComponent {
registrationModel = signal<RegistrationModel>({
email: '',
password: '',
confirmPassword: '',
});
registrationForm = form(this.registrationModel, (schemaPath) => {
required(schemaPath.email, {
message: 'Email is required.',
});
email(schemaPath.email, {
message: 'Enter a valid email address.',
});
required(schemaPath.password, {
message: 'Password is required.',
});
required(schemaPath.confirmPassword, {
message: 'Please confirm your password.',
});
});
}
This looks deceptively small, but it's doing something very specific: it is defining validation as declarative bindings to schema paths (schemaPath.email, schemaPath.password, etc.). From this point onward, the validation state of each field is available via that field's FieldState signals.
The most important point to note is that you did not explicitly wire these validators to an input, a control instance, or a subscription. You declared the rules, and the field state is now able to answer questions like:
- Has the user interacted with this field (
touched()/dirty())? - Is it currently invalid (
invalid())? - What are the current structured errors (
errors())? - Is
asyncvalidation still running (pending())?
Those are signals, meaning the UI can react to them without you orchestrating updates.
Now we can render errors (Listing 3) in a way that stays readable as the form grows. Angular's docs show the canonical pattern: only show errors once a field is touched and then iterate over errors() to display messages.
Listing 3: Rendering errors
<form novalidate>
<label>
Email
<input type="email" [formField]="registrationForm.email" />
</label>
@if (registrationForm.email().touched() && registrationForm.email().invalid()) {
<ul class="error-list">
@for (error of registrationForm.email().errors(); track error) {
<li>{{ error.message }}</li>
}
</ul>
}
<label>
Password
<input type="password" [formField]="registrationForm.password" />
</label>
@if (registrationForm.password().touched() && registrationForm.password().invalid()) {
<ul class="error-list">
@for (error of registrationForm.password().errors(); track error) {
<li>{{ error.message }}</li>
}
</ul>
}
<label>
Confirm password
<input type="password" [formField]="registrationForm.confirmPassword" />
</label>
@if (registrationForm.confirmPassword().touched() && registrationForm.confirmPassword().invalid()) {
<ul class="error-list">
@for (error of registrationForm.confirmPassword().errors(); track error) {
<li>{{ error.message }}</li>
}
</ul>
}
</form>
This is the first place where Signal Forms feel different at scale.
In older patterns, you often end up checking specific error keys in templates (hasError('required'), hasError('email'), etc.) and duplicating error-message mapping logic across components. Here, error messages are already part of the validation definition (the schema rule options), and errors() returns the current list of errors that apply. The template doesn't need to know which validators exist. It only needs to know when to reveal errors and how to render them.
That separation is subtle, but it's one of the main reasons schema-based validation remains readable. Validation rules are described once, next to each other, in the schema. Rendering stays generic.
There's also an important nuance in how you interpret valid() and invalid(). invalid() being true does not mean validation is “done,” and !valid() is not the same thing as invalid(). Angular's FieldState API explicitly documents this: invalid() is true when there are errors even if async validation is still pending, while valid() is only true when there are no errors and no pending async validators. This becomes critical later when we add asynchronous validation, because it prevents you from accidentally enabling submit while remote checks are still running.
At this stage, we have field-level rules that behave predictably, and we have a rendering strategy that doesn't degrade as we add more validators. But we still haven't solved the validation problem that causes the most real-world pain: constraints that depend on other fields.
That is the next step: cross-field validation (password confirmation and conditional requirements) without falling back to group validators and imperative coordination.
Cross-field Rules Without Group-validator Choreography
In reactive forms, password confirmation is the classic “why is this so annoying?” example. You end up writing a group validator, reaching into controls, and deciding where the error should live. It works, but it scales poorly because the rule is not collocated with the field it affects, and it depends on execution order.
Signal Forms gives you a cleaner primitive: custom validators are created with validate(). The validator callback receives a context object that includes value() for the current field and helper methods like valueOf(otherPath) to read other fields. This is not a workaround; it's part of the intended model for schema-driven logic.
Listing 4 shows the password confirmation using validate() on the confirmPassword field, comparing it to password:
Listing 4: Cross validation
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { email, form, FormField, required, validate } from '@angular/forms/signals';
interface RegistrationModel {
email: string;
password: string;
confirmPassword: string;
}
@Component({
selector: 'app-registration',
standalone: true,
imports: [FormField],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './registration.component.html',
})
export class RegistrationComponent {
registrationModel = signal<RegistrationModel>({
email: '',
password: '',
confirmPassword: '',
});
registrationForm = form(this.registrationModel, (schemaPath) => {
required(schemaPath.email, {
message: 'Email is required.',
});
email(schemaPath.email, {
message: 'Enter a valid email address.',
});
required(schemaPath.password, {
message: 'Password is required.',
});
required(schemaPath.confirmPassword, {
message: 'Please confirm your password.',
});
validate(schemaPath.confirmPassword, ({ value, valueOf }) => {
const confirm = value();
const password = valueOf(schemaPath.password);
// Avoid showing mismatch when either side is still empty.
if (!password || !confirm) return null;
if (confirm !== password) {
return {
kind: 'passwordMismatch',
message: 'Passwords do not match.',
};
}
return null;
});
});
}
What matters here is not the comparison itself, but the shape of the solution. The rule is defined once in the schema, bound to a specific field path, and evaluated automatically whenever either dependency changes. Validation does not “run” because something emitted; it changes because the underlying state changed.
This also keeps your error placement intentional. The error is produced on confirmPassword, so registrationForm.confirmPassword().errors() is the single place the UI reads from. You don't need group-level logic just to “push” the error onto the correct control.
For example, imagine we extend the model:
interface RegistrationModel {
email: string;
password: string;
confirmPassword: string;
applyDiscount: boolean;
promoCode: string;
}
Then in the schema:
required(schemaPath.promoCode, {
message: 'Promo code is required for discounts.',
when: ({ valueOf }) => valueOf(schemaPath.applyDiscount)
});
Two things are worth calling out.
First, this is still validation logic, not visibility logic. The business rule is “promo code required when discounts are applied.” Whether you show that error immediately or only after submit is a separate decision we will model later through interaction state signals (touched(), dirty(), and eventually submit state). Keeping those concerns separate is how validation schemas remain readable as they grow.
Second, conditional validation composes naturally with the rest of the system because hidden/disabled fields skip validation until they become interactive. Angular explicitly documents this: validation runs on change for interactive fields, and disabled/hidden fields don't run validation.
Form-level Validity as Composition, Not Aggregation
Once you have field rules and cross-field rules, you inevitably want a form-level answer: “Can I proceed?” In older models, this typically involves walking a control tree or trusting a status stream that may still be pending.
Signal Forms make this simpler because the form itself is a field tree root, and when you call it, you can read state signals just like any other field. Angular's field state guide even shows using profileForm().dirty() at the form root to detect unsaved changes.
That means form-level checks become direct and explicit:
const isDirty = this.registrationForm().dirty();
const isValid = this.registrationForm().valid();
const isInvalid = this.registrationForm().invalid();
const isPending = this.registrationForm().pending();
The most important nuance here is the same one we discussed earlier at the field level: valid() is not the same as !invalid(), because async validation can make both valid() and invalid() false simultaneously. Angular's FieldState documentation calls this out directly.
So, if your form has async rules (we will add them later), enabling a submit button based on !form().invalid() is subtly wrong: it treats “still validating” as acceptable. The correct check for “ready to submit” is typically form().valid(), because that implies no errors and no pending validators.
Error Objects That Scale Beyond Strings
A hidden advantage of schema-based validation is that errors are not just strings. SignalForms model each error as an object with structure. The validation guide spells out the core properties: kind (what rule failed) and optional message.
The field state guide adds an important detail: errors also include a FieldTree reference indicating where the error occurred.
That may sound like an implementation detail, but it's the basis for scalable error handling. When your UI can treat errors generically—iterate errors(), render messages, group by kind, or map kinds to localized copy, you avoid hardcoding template logic around every validator combination.
It also lets you write custom validators that produce domain-oriented errors rather than one-off text. Angular's validation guide shows validate() returning a structured error object (or null/undefined for valid).
This is the moment validation stops being “a Boolean plus some strings” and becomes something you can treat as a first-class part of your form architecture.
Validation Truth Versus Error Visibility
One of the most common mistakes in form design is treating validation and error display as the same concern. They are not.
Validation answers a factual question: Is the current state acceptable according to the rules?
Error visibility answers a UX question: should the user see a problem right now?
In older form models, these concerns are often entangled because validation is driven by events. Developers end up encoding visibility rules directly into validators or scattering conditional logic throughout templates. The result is fragile behaviour that changes when timing changes.
Signal Forms make the separation explicit because the validation state and interaction state are exposed independently through FieldState. A field can be invalid without showing errors. It can be touched without being invalid. It can be pending without being either valid or invalid. None of these states imply the others.
That separation is not accidental; it is what allows validation to scale.
At the field level, visibility rules usually depend on the interaction state. The Angular documentation shows the canonical pattern: only display errors once a field has been touched. The important point is not the pattern itself, but where it lives. The rule does not belong in the schema. It belongs in the view layer, derived from a state that already exists.
@if (registrationForm.email().touched() && registrationForm.email().invalid()) {
<ul class="error-list">
@for (error of registrationForm.email().errors(); track error) {
<li>{{ error.message }}</li>
}
</ul>
}
This may look familiar, but the difference from legacy approaches is subtle and important. We are not checking specific error keys. We are not coupling the template to the validation rules themselves. We are simply saying: if the field has been interacted with and the derived validation state says it is invalid, show whatever errors currently apply.
As validation rules change, the template does not.
This pattern also scales naturally to different UX strategies. Some teams prefer to show errors only after submission. Others prefer progressive disclosure. In SignalForms, those strategies are expressed by composing interaction state with validation state, not by rewriting validators.
For example, a submitAttempt flag can live alongside the form and participate in visibility decisions without polluting validation logic:
submitAttempted = signal(false);
if (
(registrationForm.email().touched() || submitAttempted()) &&
registrationForm.email().invalid()
) {
// render errors
}
Nothing about validation changes here. Business rules remain business rules. UX behaviour remains adjustable.
This separation becomes critical in large forms where different sections may have different visibility policies. Because Signal Forms expose state directly, those policies can vary without requiring multiple validator variants or control duplication.
Form-level Visibility and Readiness
At the form level, the same principle applies. There is a temptation to reduce everything to a single Boolean-like form.invalid, but Signal Forms intentionally gives you more nuance.
The form root exposes the same FieldState signals as individual fields. That means you can distinguish between three different situations that are often conflated:
- the form has errors (
invalid()===true) - the form has no errors but async validation is still running (
pending()===true) - the form is truly ready (
valid()===true)
This distinction matters. A form that is pending is not ready to submit, even though it may not yet be invalid. Treating “not invalid” as “valid” is one of the most common async-validation bugs in event-driven forms.
In a signal-first model, readiness is simply derived:
const canSubmit = this.registrationForm().valid();
That expression remains correct regardless of how many synchronous or asynchronous rules exist, because valid() only becomes true once all validators have settled successfully.
Asynchronous Validation as State, Not Coordination
Asynchronous validation is where many form abstractions fall apart. Network latency introduces time, and time introduces ordering problems. Older models attempt to solve this by composing streams, cancelling subscriptions, or manually tracking pending flags.
Signal Forms take a different approach: async validation updates the state, and the rest of the system reacts to that state.
The API reflects this explicitly through validateHttp() and the pending() signal. The documentation notes two key behaviors that are easy to miss but fundamental to correctness:
- synchronous validators always run first
asyncvalidators only run ifsyncvalidation passes
This means async checks never override local rule failures, and pending state only appears when it actually matters.
Here is an example of async email uniqueness validation added to our existing schema:
import { validateHttp } from '@angular/forms/signals';
validateHttp(
schemaPath.email,
(email) => this.http.get(`/api/users/exists`,
{ params: { email } }),
{
mapResponse: (exists) =>
exists
? {
kind: 'emailTaken',
message: `This email address
is already in use.`,
}
: null,
}
);
Several important things are happening here, none of which require manual orchestration.
First, the async validator is declared alongside the synchronous rules for the same field. The schema remains the single source of truth for validation.
Second, the validator produces structured errors just like synchronous rules. The UI does not need to care whether an error came from local logic or a remote call. It simply renders errors().
Third, the pending state is exposed automatically through email().pending(). This allows the UI to communicate progress without guessing:
@if (registrationForm.email().pending()) {
<span class="hint">Checking availability…</span>
}
No subscriptions. No cancellation logic. No race-condition guards. If the user types again, the pending state updates and previous results are discarded by the framework.
Most importantly, form readiness remains correct. While async validation is running, form().valid() is false. The submit button stays disabled without any additional code.
This is the essence of treating async validation as state rather than as a process to manage.
Why This Structure Holds Up Under Real Complexity
At this point, the pattern should be clear.
Validationrules are declared once, in theschema.- Validation results are read from
FieldStatesignals. - Visibility rules compose validation state with interaction state.
Asyncvalidation participates in the samemodelwithout special cases.
What you gain is not just cleaner code, but predictable behaviour. There is no hidden lifecycle where validation sometimes runs and sometimes doesn't. Every piece of logic is expressed as a relationship between states.
This is what allows Signal Forms validation to remain readable even as forms grow large, rules become conditional, and asynchronous checks multiply.
Error Modeling at Scale: Why Strings Break Down
Most form validation systems start with strings.
A validator fails, a message is produced, and the UI displays that message. For small forms, this works well enough. The problems only appear later, when the form grows, when multiple teams touch the same codebase, or when requirements change in ways the original design did not anticipate.
String-based error models collapse because they encode too much meaning into something that has no structure.
Once errors are represented purely as text, every downstream concern becomes brittle. Localization requires replacing strings everywhere or introducing parallel lookup tables. Analytics and logging become guesswork because there is no stable identifier for what failed, only how it was phrased. UX variations require branching logic in templates because the UI has no reliable way to distinguish one kind of error from another beyond matching text.
Angular's Signal Forms API avoids this trap by treating errors as structured objects, not strings. Each validation failure produces an object with a kind that identifies the rule that failed, and optional metadata such as a human-readable message. The important part is not the message; it is the fact that the error has identity.
This design choice is easy to underestimate because the simplest examples still render messages directly. But the moment you step beyond trivial forms, that structure becomes the difference between validation logic that scales and validation logic that ossifies.
Consider the password mismatch error we introduced earlier:
return {
kind: 'passwordMismatch',
message: 'Passwords do not match.'
};
The UI may choose to display a message, but it is no longer required to. It could just as easily map kind to localized copy, group errors by category, or suppress certain kinds of errors under specific conditions. None of that requires changing the validator.
This is what “error modelling” actually means: errors are no longer presentation artifacts. They are domain signals.
The inclusion of FieldTree metadata in error objects reinforces this idea. Errors know where they occurred, independently of how the UI is structured. That makes it possible to aggregate errors at the form level, display summaries, or log validation failures without manually threading context through the system. The form state already knows.
In practice, this allows teams to draw a clean line:
- Validation logic defines what
rulesexist and whatfailed - Presentation logic decides how and when to communicate that failure
This separation is not theoretical. It directly affects maintainability. When product requirements change, new languages, new accessibility rules, new UX policies—validation schemas remain stable. Only the rendering layer adapts.
That stability is what allows validation code to survive long-lived applications.
Conclusion
Validation is where form architectures are tested, not where they are demonstrated.
Simple examples make most approaches look equivalent. It is only under real-world pressure, cross-field dependencies, asynchronous checks, evolving UX rules, and long-lived codebases, that architectural choices reveal their cost.
In this article, we treated validation not as a procedural step in a form's lifecycle, but as a derived state. Field-level rules, cross-field constraints, and form-level readiness were expressed declaratively through Angular's Signal Forms schema, and their outcomes were read consistently through FieldState. Validation truth was kept separate from error visibility. Asynchronous checks participated in the same model without introducing subscriptions, coordination logic, or timing hazards.
The result is not just cleaner syntax. It is a system where validation behaviour is predictable because it is a function of the current state, not of past events.
This matters because forms do not exist in isolation. They eventually submit data, trigger side effects, coordinate with application state, and interact with navigation. Validation is the foundation that determines whether those transitions are safe.
In the next part of this series, we will build on this foundation and look at submission flows and side effects: how to move from “valid state” to “committed action” using signals, without falling back to imperative handlers or reintroducing hidden orchestration. Validation will remain in place, unchanged, while the form's responsibilities expand.
That is the real payoff of modelling validation as derived state: it stays correct while everything else moves around it.



