useClosestFormState()
Observe submission history through one interface for Form Nodes, Angular Reactive Forms, and Angular template-driven forms. The hook returns a stable object whose properties are signals. It is independent of the forms API used by the caller, but requires Angular dependency injection.
Custom controls already using useFormNodeState() can access this entire facade through
state.form, or read state.formSubmitted() directly; a second helper call is unnecessary. See
nearest form state.
Signature
useClosestFormState(): ClosestFormState;
| Property | Signal value | Meaning |
|---|---|---|
connected | boolean | A supported form is available. |
source | 'formNode' | 'formGroup' | 'ngForm' | null | The API supplying the state. |
submitted | boolean | The active form has recorded a submission attempt, including an invalid attempt. |
formNode | CallableNodeApi<FormApi<any>> | null | The Form Nodes form's callable API, when available. |
Without a form, connected() and submitted() are false; source() and formNode() are null.
formNode is always a signal property. Its value is null for Reactive Forms and NgForm;
it never contains an Angular FormGroup or directive.
One component for three forms APIs
The comments below identify two suggested component files. The same status component works under all three forms. Its optional Form Nodes-specific button uses the original callable API.
import { Component } from '@angular/core';
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { field, form, FormNodeDirective, useClosestFormState } from '@ngblocks/form-nodes';
// submission-status.component.ts
@Component({
selector: 'app-submission-status',
template: `
@if (formState.submitted()) {
<p>Please review any errors before continuing.</p>
}
@if (formState.formNode(); as node) {
<button type="button" (click)="node.reset()">Reset Form Nodes form</button>
}
`,
})
export class SubmissionStatus {
formState = useClosestFormState();
}
// checkout.component.ts
@Component({
imports: [FormNodeDirective, FormsModule, ReactiveFormsModule, SubmissionStatus],
template: `
<form [formNode]="checkout">
<input [formNode]="checkout.name" />
<app-submission-status />
<button type="submit">Save</button>
</form>
<form [formGroup]="legacyCheckout">
<input formControlName="name" />
<app-submission-status />
<button type="submit">Save</button>
<button type="reset">Reset</button>
</form>
<form>
<input name="name" [(ngModel)]="name" />
<app-submission-status />
<button type="submit">Save</button>
<button type="reset">Reset</button>
</form>
`,
})
export class Checkout {
checkout = form({ name: field('') });
legacyCheckout = new FormGroup({ name: new FormControl('') });
name = '';
}
formState.formNode()?.() reads the Form Nodes form's exposed value; use its API methods for
operations such as formState.formNode()?.reset(). The API is collision-safe: a child called submitted does not shadow
formState.formNode()?.submitted(). Access children through .children.
For reusable custom controls, combine this hook's submitted() with
useFormNodeState()'s touched() and invalid() signals.
Resolution and priority
Call once in a component or directive initializer. Resolution follows Angular's injector tree,
starting on the current element, rather than searching DOM ancestors or an HTML form attribute.
- The nearest injectable
FORM_NODEbinding resolves its model's owningform(). If present, Form Nodes wins, even when an Angular form is also visible. - Otherwise, the nearest Angular
ControlContainerresolves its owningFormGroupDirectiveorNgForm. NestedformGroupName,formArrayName, andngModelGroupcontainers retain their root form's submission history. A nested Angular form selects its own root. - Without either supported source, the facade exposes its neutral defaults.
Bindings and Angular containers are selected once by DI. Form Nodes ownership remains reactive:
rebinding or model reparenting updates formNode(), source(), and submitted(). A bound node
without a model form owner falls back to Angular; it does not skip to a farther FORM_NODE binding.
Angular Signal Forms and a plain native form without an Angular directive are not sources for this hook.
Dialogs follow their supplied injector or viewContainerRef, as described below.
Submission and reset timing
Form Nodes delegates directly to the owning form's submitted() signal. Angular directives are
adapted from their current public submitted flag, submission events, and control events.
A component created after submission sees the current history immediately.
Angular submissions update the facade during ngSubmit. Angular resetForm() clears its flag
after resetting the controls, so the facade reconciles control events in a microtask. It also
reconciles after rendering to observe silent resets and replaced Reactive Forms models.
Use the directive's resetForm() or the native reset button to reset Angular submission history;
resetting a FormGroup alone does not clear its directive's submitted flag.
Subscriptions and queued updates stop when the consuming component or directive is destroyed.
Avoid eagerly reading formNode() before a binding's required input is initialized; templates
and lazy computed expressions follow the normal Angular lifecycle.
Dialogs and overlays
The overlay's DOM position does not determine form ownership. For a Material dialog, pass
viewContainerRef from a component inside the intended form binding scope to MatDialog.open().
The dialog then inherits that injector context, and its facade observes submission and reset.
A dialog opened from the root injector without that context normally receives neutral state.
A custom dialog injector determines visibility through its own provider hierarchy.
Submission-aware errors
These two suggested component files show an error component receiving a field while discovering
submission state through DI. Keep it inside the intended binding scope; a sibling input's
[formNode] binding does not supply an ancestor context to the component.
import { Component, computed, input } from '@angular/core';
import { field, form, required, useClosestFormState, FormNodeDirective, type FieldNode } from '@ngblocks/form-nodes';
// submission-errors.component.ts
@Component({
selector: 'app-submission-errors',
template: `
@if (showErrors()) {
@for (error of node().errors(); track $index) {
<p>{{ error.message ?? error.kind }}</p>
}
}
`,
})
export class SubmissionErrors {
node = input.required<FieldNode>();
formState = useClosestFormState();
showErrors = computed(() => {
const node = this.node();
return node.invalid() && (node.touched() || this.formState.submitted());
});
}
// profile-editor.component.ts
@Component({
imports: [FormNodeDirective, SubmissionErrors],
template: `
<form [formNode]="form">
<input [formNode]="form.name" />
<app-submission-errors [node]="form.name" />
<button type="submit">Save</button>
<button type="reset">Reset</button>
</form>
`,
})
export class ProfileEditor {
form = form({
name: field('', [required]),
}, {
onSubmit: async value => { await this.save(value.name); },
});
async save(name: string | null) {
// Replace with an application service call.
await Promise.resolve(name);
}
}
With nested model forms, the nearest binding determines the owner. Bind the nested form in the component's scope when it should supply submission history. The field input alone does not change which binding the hook resolves.
Migration
useClosestFormState() replaces useClosestForm(). Replace the old hook call with the new one,
read common history through formState.submitted(), and use formState.formNode() for optional
Form Nodes-specific values and operations. See the migration guide.