DEV Community

Cover image for validateHttp() Has No Async Machinery: A Trace From Signal Forms Down to fetch() πŸ”πŸš€
Giorgio Galassi
Giorgio Galassi

Posted on AI-assisted

validateHttp() Has No Async Machinery: A Trace From Signal Forms Down to fetch() πŸ”πŸš€

Let's be honest: async validation is the part of any forms library where you brace yourself. Debouncing, cancelling the request the user just invalidated by typing another character, keeping a "checking..." spinner honest, not letting a slow response overwrite a fast one. Every library that has ever done this has grown a pile of bespoke machinery for it.

So when Signal Forms shipped validateHttp() and it just worked, I wanted to see the pile. I opened the source expecting a few hundred lines of async bookkeeping, and instead found a function whose entire body is a single call to something else.

That turned into a trace all the way down, from a form field to the line where bytes actually leave the browser. Six layers, and only two of them add anything you could call new async machinery.

βœ… Availability: validateHttp() is @publicApi 22.0, stable. Every source reference in this article is pinned to the v22.1.1 tag, so the line numbers stay valid even as main moves.

🧩 The View From Outside

The usage is unremarkable, which is the point. You declare that a field validates against an endpoint, and you're done:

const schema = form(this.model, (path) => {
  validateHttp(path.username, {
    request: ({ value }) => `/api/username-available?u=${value()}`,
    debounce: 300,
    onError: () => ({ kind: 'server-unreachable' }),
    onSuccess: (res: { available: boolean }) =>
      res.available ? undefined : { kind: 'username-taken' },
  });
});
Enter fullscreen mode Exit fullscreen mode

Sync validators run first, the request waits until they pass, field().pending() is true while it's in flight, and typing again cancels the previous call. If you've read Part 3 of my Signal Forms series, that's the behaviour contract you already know. The question here is who implements it.

πŸ” Layer 1: validateHttp() Is a Delegation

Here is the whole function, from validate_http.ts:

export function validateHttp(path, opts) {
  validateAsync(path, {
    params: opts.request,
    debounce: opts.debounce,
    factory: (request) => httpResource(request, opts.options),
    onSuccess: opts.onSuccess,
    onError: opts.onError,
    when: opts.when,
  });
}
Enter fullscreen mode Exit fullscreen mode

That's it. No await, no subscription, no cancellation logic. The URL function you wrote becomes the params of something, and httpResource() gets handed over as a factory. Out of the whole file, that factory is the only line that does anything HTTP-related; everything else naming HTTP is an import, a type or a doc comment.

So the async machinery is in validateAsync. Let's go there.

🧠 Layer 2: Three Behaviours, Zero New Mechanisms

validate_async.ts is where I expected the pile to be. It isn't there either. Three behaviours, each one built from something that already existed.

Sync-before-async gating is just returning undefined. The computation that feeds the resource's params does this:

if (validationState.shouldSkipValidation() || !validationState.syncValid()) {
  return undefined;
}
Enter fullscreen mode Exit fullscreen mode

A resource() whose params computation returns undefined sits in idle and never calls its loader β€” loadEffect() returns early as soon as it sees extRequest.request === undefined. That's documented public behaviour, not a forms detail. The docs promise that async validation runs only after all synchronous validation passes. Nobody implemented that promise here. It falls out of a rule that was already there.

pending() is a resource status, read through a validator. The async error rule is a switch (lightly condensed here β€” the real one wraps each result in addDefaultField() to attach it to the field):

switch (res.status()) {
  case 'idle':                      return undefined;
  case 'loading': case 'reloading': return 'pending';
  case 'resolved': case 'local':
    return res.hasValue() ? opts.onSuccess(res.value()!, ctx) : undefined;
  case 'error':                     return opts.onError(res.error(), ctx);
}
Enter fullscreen mode Exit fullscreen mode

Six resource statuses collapse into four form outcomes. field().pending() holds no state of its own, it's a projection of res.status().

Debounce composes two existing primitives instead of writing a timer:

if (opts.debounce !== undefined) {
  const debouncedResource = debounced(() => params(), opts.debounce);
  const wrappedParams = computed(() => Ι΅chain(debouncedResource));
  return opts.factory(wrappedParams);
}
Enter fullscreen mode Exit fullscreen mode

debounced() is a public (still experimental) @angular/core primitive, and Ι΅chain is the internal side of ctx.chain(), the resource-composition helper you reach through a params context. The file does declare export function chain(), but it carries no @publicApi marker and is not surfaced on @angular/core's public entry point, which is why the forms code reaches it through the Ι΅-prefixed alias rather than importing it by name. The forms team is chaining a debounced resource into a loader's params exactly the way you would chain any two resources in your own code. Worth noticing that they're dogfooding an experimental API in a stable feature.

βš™οΈ Layer 3: httpResource() Swaps the Loader

httpResource() extends ResourceImpl and doesn't override any of the loading logic. It passes in a loader that subscribes to HttpClient, and it adds three signals:

Signal What it is Resets on request change?
progress linkedSignal sourced from extRequest Yes, by the graph
statusCode linkedSignal sourced from extRequest Yes, by the graph
headers computed over a linkedSignal, gated on status Yes, plus stays undefined until resolved/error

So changing the request resets all three with no reset code involved β€” and the gate on headers means you never read half-populated headers mid-flight.

The one place a reset is written by hand is override set(), which clears all three when you write a value locally. The signal graph covers the request path; the local-write path doesn't go through it.

One line inside that loader is the bridge between the signal world and the HTTP world:

sub = this.client.request(request!).subscribe({ /* ... */ });
Enter fullscreen mode Exit fullscreen mode

πŸ”§ Layer 4: The Pipeline Everything Shares

ResourceImpl is the actual engine, and it's four reactive nodes. The one I'd single out is the state machine, because it explains a UI behaviour you've probably relied on without asking why:

// the branch taken when the resource already has previous state
status = request === undefined ? 'idle' : 'loading';
Enter fullscreen mode Exit fullscreen mode

There are two of these, one per branch of an if/else inside that linkedSignal. The first covers initialisation and can also resolve straight from an initial stream; the second is the one above, taken when params change on a resource that already exists. Both send undefined to 'idle'. That expression runs inside a linkedSignal, which means it's not behind an await. linkedSignal is lazy, it recomputes on read rather than on write, but that recomputation is pure and synchronous: the first read of status() after your params change already returns 'loading', and pending() is already true, in the same tick, before any promise exists. Only the data is async. If you've ever wondered why Signal Forms' pending state doesn't flicker, that's the reason: the state transition doesn't live inside the async function.

The loader itself is called by exactly one effect whose only tracked dependency is the request. Nobody calls the loader imperatively. The graph decides that work should happen, and a single effect does it. It's the same lazy, pull-based model I traced in Internals #1, which is a good sign: you learn this machine once and it keeps showing up.

πŸ”Œ Layer 5: Where the Trace Goes Dark

Follow client.request(...) downward and you hit a wall. HttpClient hands off to HttpHandler, HttpHandler hands off to HttpBackend, and both of those are abstract classes with a single handle() method and no implementation. Grep as hard as you like, no import leads to the code that touches the network.

That's because the binding isn't an import, it's a provider. From provider.ts, reformatted here to fit the width (the real HttpBackend entry uses a block-bodied factory, and each object sits on its own lines):

const providers: Provider[] = [
  HttpClient,
  FetchBackend,
  HttpInterceptorHandler,
  {provide: HttpHandler, useExisting: HttpInterceptorHandler},
  {provide: HttpBackend, useFactory: () => inject(FetchBackend)},
  {provide: HTTP_INTERCEPTOR_FNS, useValue: xsrfInterceptorFn, multi: true},
];
Enter fullscreen mode Exit fullscreen mode

provideHttpClient() is the only place in the framework where those two abstract tokens get a concrete answer. Two details are easy to get wrong here. First, HttpClient itself is @Injectable({providedIn: 'root'}) and FetchBackend is auto-provided too, so both classes are reachable from a bare injector. Forgetting provideHttpClient() doesn't fail on HttpClient, it fails with No provider for HttpHandler, because the class resolves and its dependency doesn't. Second, re-listing HttpClient and the backends in that array isn't redundant. It re-provides them at the environment injector where you called the function. That's what lets a lazy route have its own interceptors, instead of everything collapsing onto one root instance.

This is also why swapping the transport is a one-line change:

Feature What it adds Status at v22.1.1
withFetch() {provide: HttpBackend, useExisting: FetchBackend} Deprecated β€” "FetchBackend is the default HttpBackend"
withXhr() {provide: HttpBackend, useExisting: HttpXhrBackend} Supported β€” still needed for upload progress and JSONP

🌐 Layer 6: The Floor

FetchBackend is where Angular stops and the platform starts:

private readonly fetchImpl =
  inject(FetchFactory, {optional: true})?.fetch ?? ((...args) => globalThis.fetch(...args));

const fetchPromise = this.ngZone.runOutsideAngular(() =>
  this.fetchImpl(request.urlWithParams, {signal, ...init}),
);
Enter fullscreen mode Exit fullscreen mode

Even the last hop is injectable. And notice handle() returns new Observable(...), so nothing was sent until layer 3 called .subscribe(). The cold Observable is the hinge the whole chain turns on: the resource's effect decides when, and the subscription is the trigger.

Cancellation runs back up the same path in reverse. Typing another character changes the params signal, so the effect aborts its AbortController, the httpResource loader unsubscribes, the RxJS teardown aborts a second AbortController inside FetchBackend, and that one is the signal handed to fetch. Four different cancellation idioms relayed in sequence, with a staleness check at the top that rejects a late response by object identity in case anything slips through.

βœ… Closing Thoughts

Lined up, the whole stack is one pipeline with one part swapped at each level:

Layer What it swaps What it adds
validateHttp() The params Nothing at all β€” the body is one delegating call
validateAsync() The params again, now gated and debounced The translation from resource status into form errors
httpResource() The loader headers(), statusCode(), progress()
ResourceImpl Nothing β€” it is the pipeline Status, cancellation, staleness
HttpClient The transport, through DI Interceptors, XSRF, the transfer cache
FetchBackend Nothing either β€” it's the floor Chunked body reading, progress, timeouts, and finally globalThis.fetch

FetchBackend is the one row worth a caveat: there is real code down there, it just isn't async validation code. None of the debouncing, cancelling or pending-state logic we came looking for lives below ResourceImpl.

The reason this matters beyond trivia: there's no forms-specific async system to learn, work around, or wait on the team to extend. If you understand resource(), you already understand validateHttp(), and anything it does you can reproduce with public APIs. Gate a loader by returning undefined. Render pending state off status(). Debounce by chaining a debounced() resource into your params. That's the entire feature.

It's also a fair signal about the abstraction's honesty. A thin layer over primitives you can reach yourself is one you can debug at 6pm on a Friday, and one that inherits every improvement made underneath it for free.

If you want to walk it yourself, clone the repo at v22.1.1 and read in this order: validate_http.ts, validate_async.ts, common/http/src/resource.ts, then core/src/resource/resource.ts (the constructor, then loadEffect). A nice runtime check: provide a custom FetchFactory that logs, then type into a validateHttp field and watch one line appear at the bottom of a five-layer stack.

For the usage side of Signal Forms, Part 1 through Part 4 of the series cover the API from the outside. This one was the floor beneath it.


If you found this helpful, follow me here and on LinkedIn for more deep dives into Angular, web performance, and modern frontend development.

I also send the longer, more opinionated version of this kind of thing to a small list β€” reading the source, and what it changes about how you build. Subscribe here.

See you in the next one! πŸ€™πŸ»
β€” G.

Top comments (0)