Async Validation, Effects, and Side Effects (Done Carefully)
**By the time asynchronous behaviour enters a form, most architectural decisions have already been made, whether consciously or not.
In the first part of this series (https://codemag.com/Article/266041/Designing-Signal-First-Form-State-Without-Recreating-Reactive-Forms), we resisted the usual impulse to “wire things up” early. Instead, we stepped back and rebuilt a form from first principles. We treated form data as a state rather than as a stream of events. A registration form was modelled as a typed signal. Validation rules were declared against that model. The UI rendered facts, validity, errors, and dirtiness without subscribing to any particular view.
That groundwork matters because asynchronous behaviour does not introduce new complexity. It exposes existing complexity.
A form that is already event-driven becomes fragile the moment server interaction is layered on top of it. A form that is state-driven, on the other hand, gains a chance to absorb asynchrony without collapsing into coordination code.
This part is about preserving that clarity when the form meets the real world.
We will look at async validation, autosave, and side effects, not as isolated techniques, but as stress tests for the underlying mental model. The goal is not to eliminate asynchrony. The goal is to eliminate the need to reason about time in order to reason about correctness.
A Necessary Detour: Why Async Feels Harder than It Should
Before looking at Angular code, it helps to step away from forms entirely.
Imagine you are designing traffic control for a large city.
One approach is to think in terms of events. Cars arrive at intersections. Sensors fire. Timers expire. Lights change. Every decision is framed around what just happened and what should happen next. To keep traffic flowing, you coordinate these events carefully. You add delays, cancellation rules, priority lanes, and fallbacks. Over time, the system grows more sophisticated and more fragile.
This is an event-driven city. It works, but understanding why it works requires replaying a timeline.
Another approach starts from a different premise. Instead of coordinating events, you describe the state. Roads exist. Intersections exist. Traffic density exists. The system continuously knows what is true right now, and decisions emerge from that understanding. Traffic lights are no longer sequences of timed reactions; they are expressions of current conditions.
Both approaches move cars. The difference is not in capability. The difference is cognitive load.
In the event-driven city, diagnosing a traffic jam means reconstructing a sequence of events. In the state-driven city, you ask a simpler question: what does the system believe to be true right now?
Forms behave the same way.
Forms as Choreography Versus Forms as Reality
Traditional form handling, especially once asynchronous behaviour is introduced, resembles the traffic-light model. Inputs emit values. Validators react. Subscriptions coordinate delays and cancellations. Autosave pipelines debounce and switch. Submission logic flips flags and waits for responses.
Nothing about this is sloppy. In fact, it is impressively expressive. But it requires developers to reason about timelines, not about truth.
When a bug appears, the question is rarely “what is the form's current state?” Instead, it becomes “what sequence of events led us here?” That question becomes exponentially harder once network latency enters the picture.
Signal Forms is an attempt to design traffic control using the second model.
It does not remove asynchrony. Servers still respond out of order. Requests still overlap. What it removes is the requirement for developers to mentally simulate time to trust correctness.
Instead, it asks: what is true right now, and what consequences should follow from that truth?
This distinction becomes decisive the moment we introduce asynchronous validation.
Async Validation as the First Fault Line
Consider email availability validation. It is the canonical example because it combines every hard aspect of async behaviour: debouncing, cancellation, pending UI feedback, stale responses, and failure handling.
In reactive forms, experienced teams typically implement this using a custom async validator (Listing 1) rather than a raw subscription.
Listing 1: Reactive forms email available validator
export function emailAvailableValidator(api: AuthApi): AsyncValidatorFn {
return (control: AbstractControl) => {
if (!control.value) {
return of(null);
}
return timer(300).pipe(
switchMap(() => {
return api.checkEmailAvailability(control.value);
}),
map(result => (result.available ? null : { emailTaken: true })),
catchError(() => of(null))
);
};
}
This is the disciplined approach.
Wired into the form, the result looks clean and declarative.
this.form = new FormGroup({
email: new FormControl('', [Validators.required, Validators.email], [emailAvailableValidator(this.authApi)]),
password: new FormControl('', Validators.required),
});
This code works. It is production-grade. Many applications ship with logic like this.
And yet, it embodies the traffic-light model.
Correctness depends on timing. Debounce windows must be chosen carefully. Cancellation semantics must be correct. Error handling must not break the stream. Pending state must be inferred rather than declared. To understand behaviour, a developer must reason about what happens over time.
As additional requirements appear, pausing validation during submission, making availability conditional on another field, surfacing failures differently, the validator grows more complex. The choreography becomes harder to follow.
This is not because async validation is hard. It is because validation is being expressed as a reaction to events rather than as a property of the state.
Validation as Truth, Not Reaction
In the signal-first model introduced earlier in this series, a field is not something you listen to. It is something you read.
Validation, whether synchronous or asynchronous, is a statement about what must be true for the current state to be acceptable.
Listing 2 shows the same email availability rule expressed using Signal Forms.
Listing 2: Signal Forms form setup and email available validator
readonly registrationForm = form(this.registrationModel, (s) => {
required(s.email, {
message: 'Email is required'
});
email(s.email, {
message: 'Enter a valid email'
});
s.email.validateAsync(async (email) => {
if (!email) return null;
const result = await this.authApi.checkEmailAvailability(email);
return result.available
? null
: {
emailTaken: 'This email is already registered'
};
});
required(s.password, {
message: 'Password is required'
});
required(s.acceptTerms, {
message: 'You must accept the terms'
});
});
Notice what has disappeared. There is no explicit debouncing logic. There is no cancellation code. There is no teardown. Those concerns have not been ignored; they have been made irrelevant.
The validator runs against the current signal value. If the value changes, previous async work no longer corresponds to reality. Stale responses are not cancelled; they are simply no longer meaningful.
This is where the traffic-light model breaks down. Once validation is expressed as a state, time stops being the primary abstraction.
Pending State Without Ceremony
In event-driven validation, pending state often leaks into ad-hoc flags because the form does not expose it cleanly. Developers end up coordinating UI feedback manually.
Signal Forms treats pending as a state.
While the async validator is running, the field is pending. That fact can be rendered directly.
@if (registrationForm().email.pending()) {
<span>Checking email availability…</span>
}
@if (registrationForm().email.invalid()) {
<span>{{ registrationForm().email.errors() }}</span>
}
There is no choreography here. The UI reflects what the form knows to be true at that moment.
This matters because pending is not cosmetic. It influences whether submission should proceed, whether the UI should be disabled, and how confident the system is in its validity snapshot.
When pending is modelled as truth rather than as a side channel, those decisions become straightforward.
Submission as Intent, Not Consequence
At this point, it is tempting to ask why submission is not automatic. If validity is reactive, why not submit as soon as the form becomes valid?
Because submission is not the truth. Submission is intent.
Submitting a form is a deliberate transition with irreversible consequences. Treating it as a reaction to the state would collapse an essential boundary.
Signal Forms enforces that boundary explicitly.
onSubmit(event: Event) {
event.preventDefault();
submit(this.registrationForm, async () => {
const dto = this.registrationForm.value();
await this.authApi.register(dto);
});
}
The form guarantees validity. The developer declares what submission means. Server errors flow back into validation, preserving a single error model.
Validation describes reality. Submission expresses choice.
Autosave: the Second Fault Line
If async validation reveals architectural cracks, autosave widens them.
Autosave is rarely essential for correctness. Because of that, it is often added late, incrementally, and without revisiting earlier assumptions. Over time, it becomes entangled with validation, submission, and UI state until the form feels fragile.
To understand why, we need to look at autosave as it is typically implemented in a mature reactive forms application.
this.form.valueChanges
.pipe(
debounceTime(500),
distinctUntilChanged((a, b) => deepEqual(a, b)),
filter(() => this.form.dirty),
switchMap((value) =>
this.draftApi.save(value).pipe(
catchError(() => EMPTY)
)
),
takeUntil(this.destroy$)
)
.subscribe();
This code reflects experience. It avoids excessive network traffic. It prevents redundant saves. It cancels outdated requests. It cleans itself up.
It also encodes autosave as time.
To understand its correctness, a developer must imagine sequences of typing, waiting, cancelling, retrying, and tearing down. As new requirements appear, pausing autosave during submission, saving only when certain fields are complete, and the choreography grows.
This is where the traffic-light model breaks down again.
A Second Analogy: the Notebook Versus the Tape Recorder
At this point, a smaller analogy helps.
Imagine taking notes during a meeting.
One approach is to record everything on tape and later reconstruct what matters by replaying the audio. This is accurate, but it is exhausting. Understanding the outcome requires replaying time.
Another approach is to maintain a living notebook. At any moment, the notebook reflects your current understanding. When something changes, you update the page. There is no replay. There is only now.
Event-driven autosave is the tape recorder. State-driven autosave is the notebook.
Signal Forms pushes autosave toward the notebook model.
Autosave as a Consequence of the State
In the signal-first model, autosave is not a reaction to keystrokes. It is a consequence of truth.
The form already knows whether it has been modified, whether validation is pending, whether submission is active, and what the current model looks like. Autosave logic can therefore be expressed as policy rather than choreography.
constructor() {
effect(() => {
const form = this.registrationForm();
const model = this.registrationModel();
if (!form.dirty()) return;
if (form.pending()) return;
if (!form.valid()) return;
this.draftApi.save(model);
});
}
This effect does not manage time. It evaluates conditions.
If the user keeps typing, the effect re-runs with the updated state. If submission begins, autosave pauses. If validation is pending, autosave waits until the system has a stable view of correctness.
There is no cancellation logic because cancellation is no longer the problem. Relevance is.
When Async Goes Wrong in Real Teams
Most teams do not break their form architecture all at once. It erodes slowly, in response to reasonable decisions made under time pressure.
A common story starts with async validation. Email availability is added first, implemented cleanly as a custom async validator. Autosave follows a sprint later, implemented as a debounced valueChanges pipeline. Submission state is added after a production incident, guarded by flags. Each change is locally correct. The system still works. Tests still pass.
The problem emerges months later, when no one on the team can confidently answer simple questions.
- Why does
autosavesometimes stop after a failed submission? - Why does the
emailfield show an error briefly and then clear itself? - Why does disabling the form during submission sometimes cancel validation and sometimes not?
At that point, the form is no longer understood as a system. It is understood as a collection of behaviors that happen to coexist. Developers stop making changes unless absolutely necessary, because every change risks breaking a timeline they no longer fully grasp.
This is the first failure mode: temporal entanglement.
Async validation, autosave, and submission are all expressed as reactions to events. Each concern introduces its own timing assumptions. Over time, those assumptions overlap. When behavior is wrong, the only way to debug it is to reconstruct sequences of emissions, cancellations, retries, and flag transitions.
This is exactly the traffic-light model failing at scale. The city still functions but diagnosing congestion requires replaying the entire day.
The Slow Drift of the Submission State
Another common failure mode appears around submission.
A team introduces a Boolean like isSubmitting to disable inputs and prevent duplicate requests. At first, this flag is set before the request and cleared afterward. It works.
Later, async validation is added. Now submission must wait for pending validators. The flag is moved. Another flag is added. Error handling becomes conditional. Eventually, submission state is no longer a single truth. It is inferred from several variables that must stay in sync.
This is where bugs become subtle.
A failed submission clears isSubmitting, but async validation restarts and disables the submit button again. A retry works locally but fails in production due to timing differences. No one changed the validation logic—but it still affects submission behavior.
This happens because submission state is being coordinated externally rather than derived from form state. The form knows whether it is pending, invalid, or stable, but submission logic is operating on a parallel channel.
In a signal-based model, this drift is much harder to introduce. Submission is explicit. Pending state is observable. Disabling behavior is derived rather than toggled. There is still complexity, but it is anchored.
This is the second failure mode: state duplication.
Recreating RxJS Inside Effect()
The third failure mode is more subtle, and it only appears after teams adopt signals enthusiastically.
Developers who are comfortable with RxJS begin to recreate familiar patterns inside effect(). Debouncing logic is added manually. Local variables track previous values. Conditional guards multiply. Before long, the effect contains more coordination logic than the original stream.
At that point, the benefit of signals evaporates. Time-based reasoning sneaks back in. The effect becomes a subscription in disguise.
What went wrong is not misuse of the API. Developers started to misuse the mental model.
Effects are not meant to orchestrate time. They are meant to express consequences of truth. When developers try to force temporal choreography into them, they recreate the same problems they were trying to escape.
This failure mode is less about code and more about habit. Teams carry their old instincts forward and apply them to a new abstraction without adjusting how they think.
Signal Forms do not eliminate complexity automatically. It rewards discipline. When used as intended, it prevents these failure modes. When misused, it simply relocates them.
Why Complexity Stops Compounding
The real advantage of the signal-first model is not that it handles async better in isolation. It is that complexity does not multiply as requirements grow.
In event-driven systems, every new async concern interacts with every existing one. Validation timing affects autosave. Autosave timing affects submission. Submission timing affects UI state. Each interaction introduces another axis of reasoning.
In a state-driven system, new concerns attach to existing truths rather than to timelines. Async validation attaches to validity. Autosave attaches to dirtiness and stability. Submission attaches to explicit intent.
The system still grows, but it grows linearly. New rules add conditions, not timelines. New features add state dependencies, not event choreography.
This is why the traffic analogy matters. A city designed around current conditions absorbs growth more gracefully than one designed around fixed sequences of reactions.
Forms are no different.
What Changes After Six Months of Using this Model
The most telling shift does not appear in code. It appears in conversations.
Teams stop asking “what fires when?” and start asking “under what conditions should this happen?” Debugging sessions become shorter because state can be inspected directly. Features like autosave and async validation stop feeling dangerous to touch.
Most importantly, forms stop being treated as fragile artifacts. They become systems that can evolve.
That is the real test of an abstraction. Not whether it works on day one, but whether it remains understandable long after the original author has moved on.
Async Without Anxiety
Async validation works in Signal Forms because it is expressed as truth. Autosave works because it is expressed as a consequence. Submission remains explicit because it expresses intent.
Asynchrony still exists. That is the difference between coordinating behavior and modeling reality.
The Illusion of Steps
The first shift is conceptual. A multi-step form does not actually have steps. It has state that is revealed progressively.
This distinction matters because once steps are treated as independent units, developers begin to split state across multiple form groups, each with its own validation lifecycle, submission logic, and coordination rules. Navigation becomes a problem of synchronizing independent systems rather than moving through a single, evolving truth.
In a signal-based model, the form remains one structure:
readonly registrationModel = signal({
account: {
email: '',
password: '',
},
profile: {
firstName: '',
lastName: '',
},
preferences: {
newsletter: false,
},
});
The UI introduces steps, not the data model.
readonly currentStep = signal(0);
Each step becomes a projection of the same underlying state:
@if (currentStep() === 0) {
<!-- account fields -->
}
@if (currentStep() === 1) {
<!-- profile fields -->
}
@if (currentStep() === 2) {
<!-- preferences -->
}
Nothing about validation, async rules, or submission changes because the form itself has not been fragmented. The system still answers a single question: what is true right now?
Steps are a UI concern. State remains unified.
Partial Validity as a First-Class Concept
The moment a form spans multiple steps, validity stops being binary.
A form can be globally invalid while still allowing forward progression. A step can be “complete enough” to proceed even if other parts of the model are untouched. In traditional approaches, this leads to step-level validators, duplicated rules, or conditional checks scattered across navigation logic.
In a signal-first model, partial validity is not inferred. It is expressed.
const stepValidity = computed(() => {
const form = this.registrationForm();
switch (this.currentStep()) {
case 0:
return form.account.valid();
case 1:
return form.profile.valid();
case 2:
return true; // optional step
}
});
Navigation becomes a consequence of the state:
nextStep() {
if (!this.stepValidity()) return;
this.currentStep.update(s => s + 1);
}
What changes here is subtle but important. We are not “running validation before navigation.” We are asking whether the current slice of state satisfies the conditions required to proceed.
This eliminates an entire category of bugs where navigation and validation drift apart, because both are derived from the same underlying truth.
Cross-Field Dependencies Without Coordination
Multi-step flows often introduce dependencies that span across steps. A decision made early in the process influences what is required later. A field in one step may invalidate a field in another. In event-driven systems, this typically results in subscriptions or imperative checks that attempt to “keep things in sync.”
This is where the signal model becomes decisive.
Dependencies are not coordinated. They are declared.
readonly isBusinessAccount = computed(() => {
return this.registrationModel().account.email.endsWith('@foo.com');
});
Validation can then depend on that derived truth (Listing 3).
Listing 3: Validating an account
readonly registrationForm = form(this.registrationModel, (s) => {
required(s.account.email);
required(s.account.password);
const lastNameReq = 'Last name is required for business accounts';
s.profile.validate((profile) => {
if (this.isBusinessAccount() && !profile.lastName) {
return {
lastNameRequired: lastNameReq
};
}
return null;
});
});
No subscriptions. No manual synchronization. No step awareness baked into validation.
The system evaluates the current state and derives correctness from it. If the email changes, the dependency updates automatically. If the user navigates back and modifies earlier input, downstream validation reflects that change without additional code.
This is the difference between coordination and composition.
Async Rules Across Multiple Pieces of State
Async validation becomes more complex in multi-step flows because it often depends on more than a single field. Consider a scenario where availability depends on both email and region, or where pricing validation depends on a combination of selected options.
In traditional systems, this introduces combinatorial complexity: multiple streams must be merged, debounced, cancelled, and coordinated.
In a signal-first model, async validation still answers the same question: what is true right now?
s.account.validateAsync(async (account) => {
const { email } = account;
const region = this.registrationModel().profile.region;
if (!email || !region) return null;
const result = await this.api.checkAvailability(email, region);
return result.available ? null : { notAvailable: 'This combination is not allowed' };
});
The validator reads from multiple signals. It does not subscribe to them.
When either input changes, the validation becomes stale. The system does not need to cancel anything explicitly because the previous result no longer corresponds to the current state. This is the same principle introduced earlier, now applied across boundaries.
Async complexity does not increase with the number of dependencies. It remains constant because the abstraction does not change.
Debounce Without Reintroducing Time
One of the common mistakes when dealing with multi-step async logic is reintroducing temporal reasoning through manual debouncing. Developers attempt to optimize network calls by layering timing logic into effects or validators, often recreating the very coordination problems they were trying to escape.
Angular Signal Forms now provides a built-in debounce capability, which allows us to express delay without collapsing back into event streams.
s.account.email.debounce(300).validateAsync(async (email) => {
if (!email) return null;
const result = await this.api.checkEmailAvailability(email);
return result.available ? null : { emailTaken: true };
});
What matters here is not the delay itself, but where it lives.
The debounce is attached to the signal, not orchestrated externally. It becomes a property of how the state stabilizes, not a timeline the developer must manage. There is no switchMap, no cancellation logic, no replaying of emissions. The system still evaluates truth; it simply waits for input to settle before doing so.
This is an important boundary. The moment debounce logic leaks into effects or navigation, time-based reasoning returns. Keeping it at the signal level preserves the mental model.
Multi-Step Flows as State Composition
At scale, the challenge is not validation, navigation, or async behavior in isolation. The challenge is composing all of them without losing the ability to reason about the system.
A multi-step form built on signals remains understandable because every concern attaches to state. Step progression is derived from partial validity. Cross-step dependencies emerge through computed relationships. Async rules evaluate combinations of current state rather than isolated inputs. Debounce stabilizes input without introducing timelines, and submission remains an explicit transition rather than a side effect of navigation.
Nothing is coordinated through events. Nothing requires reconstructing a sequence of actions to explain behavior.
This is the discipline that prevents collapse.
When Traditional Abstractions Fail
This is the point where most traditional abstractions break down.
They were designed around individual controls, not evolving systems. They assume validation happens locally, not across distributed state. They treat async behavior as something to coordinate rather than something to derive.
As requirements grow, developers compensate by adding layers: step-level forms, shared services, synchronization flags, and increasingly complex pipelines. Each layer solves a local problem while making the global system harder to reason about.
Eventually, the form becomes something teams avoid touching.
The signal-first model does not avoid complexity. It changes where complexity lives.
Instead of spreading across timelines and coordination logic, complexity is concentrated in state relationships. That concentration is what makes it manageable.
The Real Outcome
After implementing multi-step flows in this model for a while, the shift is noticeable. Developers stop asking how to synchronize steps. They start asking what conditions define progress. They stop debugging sequences of events. They inspect current state. They stop fearing async behavior. They treat it as another property of truth. This is not a small improvement.
It is a change in how forms are understood.
A multi-step form is no longer a fragile workflow stitched together over time. It becomes a system that can grow without losing coherence.
And that is the real goal.
From Coordination to Composition: What Actually Changed
This article was never about introducing a new API surface. It was about removing an entire category of problems that most teams have quietly accepted as unavoidable.
At the beginning, the focus was deliberately narrow. A single form. A single model. A controlled environment where the core idea could be established without distraction. That idea was simple, but not trivial: form state is not something that unfolds over time. It is something that exists, continuously, as truth.
Asynchronous validation was the first pressure test. Not because it is conceptually difficult, but because it exposes how fragile event-driven systems become once time enters the picture. Debounce windows, cancellation semantics, and race conditions are not inherent to validation. They are artifacts of how validation is expressed. When validation is modeled as a property of state, those concerns do not disappear, but they stop being something the developer must actively manage.
Autosave extended that pressure further. What is often treated as a background feature quickly turns into a coordination problem in traditional systems. Streams are composed, emissions are filtered, and correctness becomes tied to sequences of events. In a signal-first model, autosave becomes something fundamentally different. It is no longer a pipeline. It is a policy. It answers a question: under what conditions should the current state be persisted? Once that question is answered declaratively, the need to reason about timing fades.
The failure modes that followed were not edge cases. They were the natural consequences of mixing timelines, duplicating state, and carrying over habits from event-driven systems. Temporal entanglement, submission drift, and recreating RxJS inside effect() are not mistakes made by inexperienced teams. They are what happens when the abstraction does not match how the system is being reasoned about.
This is where the transition to multi-step forms becomes critical.
A multi-step flow is not a new feature layered on top of a form. It is a multiplication of complexity. Partial validity, cross-step dependencies, and async rules that depend on combinations of state introduce interactions that quickly overwhelm traditional approaches. The moment state is fragmented across steps, correctness becomes something that must be coordinated again. Navigation logic, validation logic, and async behavior begin to drift apart.
The signal-first model holds precisely because it does not change under this pressure.
The form remains a single structure. Steps are not units of state; they are projections of it. Partial validity is not inferred through flags; it is derived from the current state. Cross-field and cross-step dependencies are not synchronized manually; they are expressed as relationships. Async validation continues to evaluate truth, even when that truth depends on multiple inputs. Debounce, now part of the signal model itself, stabilizes input without reintroducing timelines.
Nothing new is added in terms of mental overhead. The same rules apply. That is the point.
What changes is not capability. It is stability under growth.
In event-driven systems, every new requirement introduces new interactions between timelines. Validation affects autosave. Autosave affects submission. Submission affects UI state. Each interaction compounds complexity. Over time, the system becomes something that works, but cannot be easily reasoned about.
In a state-driven system, new requirements attach to existing truths. Validation attaches to correctness. Autosave attaches to stability. Navigation attaches to partial validity. Submission remains an explicit boundary. The system grows, but it does not lose coherence.
This is the difference between coordination and composition. After working in this model for a while, the most noticeable change is not in the codebase. It is in how teams talk about forms. Questions shift from “what happens when this fires?” to “under what conditions should this be true?” Debugging stops being an exercise in reconstructing timelines and becomes an exercise in inspecting state. Features that once felt risky to modify, async validation, autosave, multi-step flows, become predictable.
That predictability is not accidental. It is a consequence of choosing the right abstraction.
The goal was never to eliminate asynchrony, nor to simplify forms to the point of triviality. Real-world forms are inherently complex. They deal with incomplete data, delayed responses, conditional requirements, and user intent that evolves over time.
The goal was to ensure that this complexity does not compound.
Signal Forms achieves this not by reducing what the system can do, but by ensuring that everything it does can be understood as a function of what is true right now.
That is the difference.
Not between synchronous and asynchronous code.
Not between simple and complex forms.
But between systems that must be coordinated…and systems that can be composed.



