Angular Guide
npm install @neutro/form
# pnpm add @neutro/form
# yarn add @neutro/formAngular 16+ is required. The adapter uses Angular signals internally.
Injection Context Requirement
Both useAngularForm and useAngularFormPath must be called inside an injection context — typically the component's constructor or a function called synchronously from the constructor. They call Angular's inject() internally (to access DestroyRef for cleanup) and will throw if called outside an injection context.
Do not call them inside
ngOnInit,ngAfterViewInit, event handlers, orsetTimeout.
useAngularForm — Global Signal
useAngularForm returns an AngularFormReturn<T> object — a state Signal containing the full FormState<T>, plus all form methods (set, validate, resetField, etc.) forwarded from the core instance. Expose state as a class property and read it in templates with state().
import { Component } from '@angular/core'
import { createForm } from '@neutro/form/core'
import { useAngularForm } from '@neutro/form/adapters/angular'
type LoginValues = { email: string; password: string }
@Component({
selector: 'app-login-form',
standalone: true,
template: `
<form (ngSubmit)="handleSubmit()">
<input
[value]="state().values.email"
(input)="form.set('email', $event.target.value, { touch: true })"
/>
@if (state().errors['email']) {
<span class="error">{{ state().errors['email'] }}</span>
}
<input
type="password"
[value]="state().values.password"
(input)="form.set('password', $event.target.value, { touch: true })"
/>
@if (state().errors['password']) {
<span class="error">{{ state().errors['password'] }}</span>
}
<button type="submit" [disabled]="state().isSubmitting">
{{ state().isSubmitting ? 'Signing in…' : 'Sign in' }}
</button>
</form>
`,
})
export class LoginFormComponent {
readonly form = createForm<LoginValues>({
initialValues: { email: '', password: '' },
validator: (values) => {
const errors: Record<string, string> = {}
if (!values.email.includes('@')) errors.email = 'Invalid email'
if (values.password.length < 8) errors.password = 'Min 8 characters'
return errors
},
})
// Must be called in the constructor (injection context)
private readonly _adapter = useAngularForm(this.form)
readonly state = this._adapter.state
async handleSubmit() {
await this.form.validate()
}
}useAngularFormPath — Field Signals
useAngularFormPath returns { value: Signal<unknown>, fieldState: Signal<{ error?, touched?, dirty? } | null> } — two independent readonly signals. Call each to read its current value: field.value() for the field's value, field.fieldState()?.error for the error message.
import { Component, input } from '@angular/core'
import { createForm } from '@neutro/form/core'
import { useAngularFormPath } from '@neutro/form/adapters/angular'
@Component({
selector: 'app-field',
standalone: true,
template: `
<label>
{{ label() }}
<input
[value]="field.value()"
(input)="onInput($event)"
/>
@if (field.fieldState()?.touched && field.fieldState()?.error) {
<span class="error">{{ field.fieldState()?.error }}</span>
}
</label>
`,
})
export class FieldComponent {
readonly formInstance = input.required<ReturnType<typeof createForm>>()
readonly path = input.required<string>()
readonly label = input.required<string>()
// Called in constructor — injection context is active
readonly field = useAngularFormPath(this.formInstance(), this.path())
onInput(event: Event) {
this.formInstance().set(
this.path(),
(event.target as HTMLInputElement).value,
{ touch: true, validate: true }
)
}
}Full Example with Zod
import { Component } from '@angular/core'
import { createForm } from '@neutro/form/core'
import { zodAdapter } from '@neutro/form/core'
import { useAngularForm, useAngularFormPath } from '@neutro/form/adapters/angular'
import { z } from 'zod'
const schema = z.object({
username: z.string().min(3, 'At least 3 characters'),
email: z.string().email('Invalid email'),
})
type Values = z.infer<typeof schema>
@Component({
selector: 'app-profile-form',
standalone: true,
template: `
<form (ngSubmit)="form.validate()">
<label>
Username
<input
[value]="username.value()"
(input)="form.set('username', $event.target.value, { touch: true, validate: true })"
/>
@if (username.fieldState()?.touched && username.fieldState()?.error) {
<span>{{ username.fieldState()?.error }}</span>
}
</label>
<label>
Email
<input
type="email"
[value]="email.value()"
(input)="form.set('email', $event.target.value, { touch: true, validate: true })"
/>
@if (email.fieldState()?.touched && email.fieldState()?.error) {
<span>{{ email.fieldState()?.error }}</span>
}
</label>
<button type="submit" [disabled]="state().isSubmitting">Save</button>
</form>
`,
})
export class ProfileFormComponent {
readonly form = createForm<Values>({
initialValues: { username: '', email: '' },
validator: zodAdapter(schema),
})
// All three called in constructor — injection context required
private readonly _adapter = useAngularForm(this.form)
readonly state = this._adapter.state
readonly username = useAngularFormPath(this.form, 'username')
readonly email = useAngularFormPath(this.form, 'email')
}Cleanup
Both hooks inject DestroyRef and register an onDestroy callback to unsubscribe from the form. You do not need to manually unsubscribe or implement OnDestroy — Angular handles it automatically when the component is destroyed.
Resetting a Single Field
resetField is exposed on the Angular adapter's return object and can be called from a component method or template:
@Component({
template: `
<input [value]="state().values.email" (input)="onEmail($event)" />
<button *ngIf="state().errors['email']" (click)="form.resetField('email')">
Reset
</button>
`,
})
export class EmailFieldComponent {
readonly form = createForm({
initialValues: { email: '' },
rules: { email: ['required', 'email'] },
})
private readonly _adapter = useAngularForm(this.form)
readonly state = this._adapter.state
onEmail(e: Event) {
this.form.set('email', (e.target as HTMLInputElement).value, { touch: true })
}
}Notes
- The adapter requires Angular 16 or later (signals API).
- If you need to create the form asynchronously (e.g. after an HTTP call), create the form instance eagerly with empty initial values and call
form.reset(newValues)when the data arrives. Do not defer theuseAngularForm/useAngularFormPathcalls. - Template expressions reading signal values (e.g.
state().errors['email']) are automatically tracked by Angular's change detection when usingOnPush— nomarkForCheck()needed.
Handling Server Errors
Use this.form.setErrors() inside your submit handler to feed API validation errors back into form state. They surface in state().errors and clear on the next validation run — no extra wiring required.
import { Component } from '@angular/core'
import { createForm } from '@neutro/form/core'
import { useAngularForm } from '@neutro/form/adapters/angular'
@Component({
selector: 'app-register-form',
standalone: true,
template: `
<form (ngSubmit)="handleSubmit()">
<input
[value]="state().values.email"
(input)="form.set('email', $event.target.value, { touch: true })"
/>
@if (state().touched['email'] && state().errors['email']) {
<span class="error">{{ state().errors['email'] }}</span>
}
<input
[value]="state().values.username"
(input)="form.set('username', $event.target.value, { touch: true })"
/>
@if (state().touched['username'] && state().errors['username']) {
<span class="error">{{ state().errors['username'] }}</span>
}
<button type="submit" [disabled]="state().isSubmitting">Register</button>
</form>
`,
})
export class RegisterFormComponent {
readonly form = createForm({
initialValues: { email: '', username: '' },
rules: { email: ['required', 'email'], username: 'required' },
})
private readonly _adapter = useAngularForm(this.form)
readonly state = this._adapter.state
async handleSubmit() {
const valid = await this.form.validate()
if (!valid) return
const res = await fetch('/api/register', {
method: 'POST',
body: JSON.stringify(this.form.getPayload()),
})
if (!res.ok) {
const { errors } = await res.json()
this.form.setErrors(errors)
}
}
}useAngularWatch — Observe Field Values
useAngularWatch must be called inside an injection context (same constraint as useAngularForm). It returns a readonly Angular Signal.
import { Component } from '@angular/core'
import { useAngularWatch } from '@neutro/form/adapters/angular'
@Component({
template: `
<p>{{ watched()['email'] }} — {{ watched()['username'] }}</p>
`,
})
export class SummaryComponent {
readonly watched = useAngularWatch(this.form, ['email', 'username'])
}Use bracket notation watched()['email'] to read from the signal — the keys are the dotted path strings.
Validation Modes
Configure when validation triggers globally and per field via validationMode in createForm:
const form = createForm({
initialValues: { email: '', password: '' },
validationMode: {
default: 'onTouched',
fields: { password: 'onChange' },
},
})For Angular template-driven inputs, use this.form.getFieldMode(path) in your component:
@Component({
template: `
<input
[value]="form.get('email')"
(input)="handleInput('email', $event.target.value)"
(blur)="handleBlur('email')"
/>
`
})
export class FormComponent {
handleInput(path: string, value: string) {
this.form.set(path, value)
if (this.form.getFieldMode(path) === 'onChange') {
this.form.validate([path])
}
}
handleBlur(path: string) {
const mode = this.form.getFieldMode(path)
if (mode === 'onBlur' || mode === 'onTouched') {
this.form.validate([path])
}
}
}