Basic Usage
import { createForm } from '@vielzeug/forge';
const form = createForm({
defaultValues: {
email: '',
password: '',
profile: { age: 0, name: '' },
},
validators: {
email: (v) => (!String(v).includes('@') ? 'Invalid email' : undefined),
password: (v) => (String(v).length < 8 ? 'Min 8 chars' : undefined),
},
});
form.set('profile.age', 30);
form.set('profile.name', 'Alice');
const email = form.get('email');
const values = form.values();Typed Paths
Forge is path-typed first. Provide a concrete values shape so field paths and value types are inferred.
type Values = {
email: string;
profile: { age: number; name: string };
};
const form = createForm<Values>({
defaultValues: { email: '', profile: { age: 0, name: '' } },
});
form.set('profile.age', 42); // TypeScript error if type is wrongDynamic shape escape hatch:
const dynamicForm = createForm<Record<string, unknown>>({});
dynamicForm.set('custom.field', 'value');Validation
await form.validate('password');
await form.validate();
await form.validate(['email', 'password']);
const controller = new AbortController();
await form.validate(controller.signal);
controller.abort();Validation result:
const result = await form.validate(['email']);
console.log(result.valid); // true only if no errors exist after this run
console.log(result.errors); // full current error map after the runSchema integration — pass a safeParse-compatible schema directly to validator:
import { createForm } from '@vielzeug/forge';
import { s } from '@vielzeug/spell';
const schema = s.object({
age: s.number().min(18, 'Must be 18+'),
email: s.string().email('Invalid email'),
});
const form = createForm({
defaultValues: { age: 0, email: '' },
validator: schema, // auto-detected as a safeParse schema
});For per-field validation with a schema, use fieldValidator from @vielzeug/forge/validators:
import { fieldValidator } from '@vielzeug/forge/validators';
import { s } from '@vielzeug/spell';
const form = createForm({
defaultValues: { age: 0, email: '' },
validators: {
age: fieldValidator(s.number().min(18, 'Must be 18+')),
email: fieldValidator(s.string().email('Invalid email')),
},
});Submission
const result = await form.submit(async (values) => {
const res = await fetch('/api/submit', {
body: JSON.stringify(values),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
});
return res.json();
});
if (!result.ok && result.type === 'validation') {
console.log(result.errors);
}
if (result.ok) {
console.log(result.value); // typed return value from the handler
}submit() always:
- Marks all known fields touched
- Runs full validation
- If invalid: returns
{ ok: false, type: 'validation', errors } - If valid: calls the handler and returns
{ ok: true, value }
Calling submit() while a submission is already in progress rejects the returned promise with a ForgeSubmitError (it's an async function, so this is never a synchronous throw) — guard with state.isSubmitting if needed.
Async Default Values
Pass an async factory to defaultValues to load initial state remotely. The form's state.isLoading / form.isLoading is true while the factory is pending.
const form = createForm({
defaultValues: async () => {
const res = await fetch('/api/user/me');
return res.json();
},
});
// isLoading is true until the factory resolves
form.subscribe((state) => {
if (!state.isLoading) renderForm(form.values());
});Connect (Field Binding)
connect() returns a live binding object with DOM event handlers and live getters. Call once per field and store the result — do not destructure. Each binding owns its own independent debounce timer; call dispose() when the field unmounts to cancel it.
const emailConn = form.connect('email');
input.addEventListener('change', (e) => emailConn.onChange(e.target.value));
input.addEventListener('blur', () => emailConn.onBlur());
// Live getters re-evaluate on every access
console.log(emailConn.value, emailConn.error, emailConn.touched, emailConn.dirty);
// On unmount: cancel any pending debounce timer
emailConn.dispose();Validation Modes
Pass a ConnectOptions object (or a ValidationModes preset) to control when validation triggers:
import { createForm, ValidationModes } from '@vielzeug/forge';
// Global default for all connect() calls
const form = createForm({
defaultValues: { email: '', password: '' },
connect: ValidationModes.onBlur, // validate each field on blur
});
// Per-field override
const emailConn = form.connect('email', ValidationModes.onChange);
const passwordConn = form.connect('password', { validateOnBlur: true, debounce: 300 });| Preset | touchOnBlur | validateOnBlur | validateOnChange | validateOnTouch |
|---|---|---|---|---|
ValidationModes.onSubmit (default) | — | — | — | — |
ValidationModes.onBlur | — | — | ||
ValidationModes.onChange | — | — | ||
ValidationModes.onTouched | — |
debounce delays auto-triggered validation by a given number of milliseconds — useful for async validators on onChange to avoid one request per keystroke.
Scoped Sub-Forms
scope(prefix) returns a sub-form whose field paths are relative to the prefix. All state and lifecycle is shared with the parent form.
const form = createForm({
defaultValues: {
name: 'Alice',
address: { city: 'New York', street: '123 Main St', zip: '10001' },
},
validators: {
'address.city': (v) => (!v ? 'City is required' : undefined),
},
});
// scope() is memoized — repeated calls with the same prefix return the same object
const address = form.scope('address');
address.get('city'); // same as form.get('address.city')
address.set('city', 'Portland'); // same as form.set('address.city', 'Portland')
address.connect('city'); // same as form.connect('address.city')
await address.validate(); // validates only address.* fields; returns scoped errors (no prefix)
await address.submit((vals) => vals); // validates and submits only address.* fieldsKey characteristics:
dispose()on a scoped form is a no-op — callparentForm.dispose()to tear down.scope.statereturns a scoped projection:errors,touchedFields,validatingFields,isDirty,isValid,isTouched, andisValidatingreflect only fields within the scope's prefix.isSubmitting,isLoading, andsubmitCountreflect the full form. Usescope.validate()orscope.submit()for scoped validity checks; their results contain relative keys and a scopedvalidflag.touchedFieldsinstatecontains full-prefixed paths. Preferscope.validate()overscope.validate([...state.touchedFields])to avoid double-prefixing.
Scoped Subscriptions
subscribeScoped delivers form state filtered to the scope's prefix. errors, touchedFields, and validatingFields use relative keys. isDirty, isValid, isTouched, and isValidating reflect only the scoped fields. The listener only fires when the scoped projection changes — mutations outside the prefix are suppressed.
const address = form.scope('address');
address.subscribeScoped((state) => {
// state.errors → { city: 'Required' } (not 'address.city')
// state.isDirty → true only when an address.* field is dirty
// does not fire when form.set('name', 'Alice') is called
renderAddressErrors(state.errors);
});Subscriptions
const stopForm = form.subscribe((state) => {
console.log(state.isValid, state.isDirty, state.errors);
});
const stopEmail = form.subscribeField('email', (field) => {
console.log(field.value, field.error, field.touched, field.dirty);
});
// Pass sync: true to receive the current snapshot immediately on subscription
form.subscribeField('email', (field) => updatePreview(String(field.value)), { sync: true });
stopEmail();
stopForm();Snapshot semantics:
form.stateandform.field(name)return stable, frozen snapshots.- Reference identity is preserved until a relevant mutation occurs.
- These are directly compatible with external-store patterns such as React
useSyncExternalStore, VueshallowRef, and the Svelte store protocol.
Streaming Validation
validateStream() runs all field validators in parallel and yields each result as it resolves. It is read-only — it does not write errors to form state. The form-level validator, if set, is yielded last with field: '_form'.
for await (const { field, error } of form.validateStream()) {
if (error) showInlineError(field, error);
}
// form.state.errors is unchanged after the loopSnapshots and Restore
Capture and replay complete form state for undo/redo or "discard changes" flows:
const draft = form.snapshot();
// ... user edits ...
form.set('email', 'different@example.com');
// Revert all changes, including errors, touched, dirty, and submitCount
form.restore(draft);Arrays
const items = form.array('items');
items.append({ name: '' });
items.prepend({ name: 'first' });
items.insert(1, { name: 'middle' });
items.remove(0);
items.move(1, 0);
items.swap(0, 1);
items.replace(0, { name: 'updated' });form.array() returns a cached helper — call it once and reuse.
Batching
Wrap multiple mutations in batch() to emit only one notification:
form.batch(() => {
form.set('firstName', 'Alice');
form.set('lastName', 'Smith');
form.touch('firstName');
});
// subscribers notified onceReset, Replace, and Patch
form.reset(); // restore all values to baseline; clear errors/touched/dirty/submitCount
form.replace({ email: '', name: '' }); // replace values and baseline; also resets submitCount
form.patch({ name: 'Alice' }); // merge specific fields into store and baseline (marks them clean)
form.resetField('email'); // restore single field to baseline
form.fields.remove('coupon'); // drop field entirely (value, touched, error, validator)patch() accepts a DeepPartial object — nested paths are flattened automatically. Useful for applying a server response without dirtying the form.
Lifecycle
form.dispose(); // tear down: abort all pending validation, clear listeners
console.log(form.disposed); // true after dispose()After dispose(), all mutating APIs throw.
Debugging
Import debugForm() from the dedicated /devtools sub-path to log per-field value/error/touched/dirty changes and submit/loading transitions via console.debug:
import { debugForm } from '@vielzeug/forge/devtools';
const detach = debugForm(form, { label: 'signup' });
// later, e.g. on unmount:
detach();It is a no-op in production (__FORGE_PROD__ set) and never imported from the main entry point, so it is tree-shaken out of production bundles entirely. See API Reference → Devtools for the full contract.
Framework Integration
Forge has no framework-specific packages — form.subscribe(), form.subscribeField(), and form.connect() are the framework-agnostic primitives every integration is built on. Each snippet below is ~10 lines of glue; keep it in a shared lib/form-hooks.ts-style module and reuse it across forms.
import { useEffect, useRef, useSyncExternalStore } from 'react';
import { createForm } from '@vielzeug/forge';
import type { ConnectOptions, FlatKeyOf, Form, TypeAtPath } from '@vielzeug/forge';
function useField<T extends Record<string, unknown>, K extends FlatKeyOf<T>>(form: Form<T>, name: K) {
return useSyncExternalStore(
(onChange) => form.subscribeField(name, onChange),
() => form.field(name),
);
}
// Live connect() binding, recreated when `name` or an option value changes, disposed on unmount
function useConnect<T extends Record<string, unknown>, K extends FlatKeyOf<T>>(
form: Form<T>,
name: K,
options?: ConnectOptions,
) {
const bindingRef = useRef<{ dispose(): void; disposed: boolean; value: TypeAtPath<T, K> } | null>(null);
if (!bindingRef.current || bindingRef.current.disposed) bindingRef.current = form.connect(name, options);
useEffect(() => {
const binding = form.connect(name, options);
bindingRef.current = binding;
return () => binding.dispose();
}, [form, name, options?.debounce, options?.touchOnBlur, options?.validateOnBlur, options?.validateOnChange]);
return bindingRef.current;
}
const form = createForm({ defaultValues: { email: '', password: '' } });
function LoginForm() {
const email = useField(form, 'email');
const conn = useConnect(form, 'email', { touchOnBlur: true });
return (
<form
onSubmit={(e) => {
e.preventDefault();
form.submit(handleLogin);
}}>
<input
value={email.value as string}
onChange={(e) => conn.onChange(e.target.value)}
onBlur={() => conn.onBlur()}
/>
{email.error && <p>{email.error}</p>}
<button type="submit">Submit</button>
</form>
);
}import { onScopeDispose, shallowRef } from 'vue';
import { createForm } from '@vielzeug/forge';
import type { FlatKeyOf, Form } from '@vielzeug/forge';
// Wraps a field in a reactive shallowRef; auto-unsubscribes when the scope tears down
function useField<T extends Record<string, unknown>, K extends FlatKeyOf<T>>(form: Form<T>, name: K) {
const ref = shallowRef(form.field(name));
onScopeDispose(form.subscribeField(name, (state) => (ref.value = state)));
return ref;
}
const form = createForm({ defaultValues: { email: '' } });
export default {
setup() {
const email = useField(form, 'email');
const conn = form.connect('email');
return { email, conn };
},
};<script>
import { createForm } from '@vielzeug/forge';
const form = createForm({ defaultValues: { email: '' } });
// Any object exposing subscribe() is a valid Svelte store — form.subscribeField() already fits
const email = {
subscribe(run) {
run(form.field('email'));
return form.subscribeField('email', run);
},
};
const conn = form.connect('email');
</script>
<form on:submit|preventDefault={() => form.submit(handleSubmit)}>
<input
bind:value={$email.value}
on:change={(e) => conn.onChange(e.target.value)}
on:blur={() => conn.onBlur()}
/>
{#if $email.error}<span>{$email.error}</span>{/if}
<button>Submit</button>
</form>Reuse the same subscribe/subscribeField pattern for useFormState/useFormValues-style helpers — swap form.subscribeField(name, ...) / form.field(name) for form.subscribe(...) / form.state (or form.values()).
Working with Other Vielzeug Libraries
With Spell
Combine Spell schemas with Forge to get typed validation rules without writing validator functions by hand.
import { createForm } from '@vielzeug/forge';
import { fieldValidator } from '@vielzeug/forge/validators';
import { s } from '@vielzeug/spell';
// Per-field validation with a Spell schema
const form = createForm({
defaultValues: { email: '', password: '', age: 0 },
validators: {
age: fieldValidator(s.number().min(18, 'Must be 18+')),
email: fieldValidator(s.string().email('Invalid email')),
password: fieldValidator(s.string().min(8, 'Min 8 characters')),
},
});
// Full-form schema validation (auto-detects safeParse)
const schema = s.object({
age: s.number().min(18, 'Must be 18+'),
email: s.string().email('Invalid email'),
password: s.string().min(8, 'Min 8 characters'),
});
const formWithSchema = createForm({
defaultValues: { email: '', password: '', age: 0 },
validator: schema,
});Best Practices
- Call
connect()once per field and store the result — never call it inside a render or update loop. Callbinding.dispose()when the field unmounts to cancel any pending debounce timer. scope()is memoized — repeated calls with the same prefix return the same object. Store the result for clarity, but it is safe to call multiple times.- Prefer
scope.validate()overscope.validate([...state.touchedFields])on scoped forms to avoid double-prefixed paths. - Wrap multi-field mutations in
batch()to emit a single subscriber notification. - Pass a
signalto long-running validators where applicable — Forge passes its own abort signal to validators ondispose(). - Set a
connectdefault increateForm()usingValidationModespresets rather than repeating per-field options. - Use
replace()after a successful async load instead ofreset()—replace()updates the baseline soisDirtyreflects changes against the new data. - Guard concurrent submissions with
form.isSubmittingorstate.isSubmitting— callingsubmit()while a submission is in progress rejects the returned promise with aForgeSubmitError(not a synchronous throw, sincesubmit()is async).