Basic Usage
import { createForm } from '@vielzeug/formit';
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
Formit is path-typed first. Prefer 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);Dynamic shape escape hatch:
const dynamicForm = createForm<Record<string, unknown>>({});
dynamicForm.set('custom.field', 'value');Validation
await form.validateField('password');
await form.validateAll();
await form.validateTouched();
await form.validateFields(['email', 'password']);
const controller = new AbortController();
await form.validateAll(controller.signal);
controller.abort();Validation result structure:
const result = await form.validateFields(['email']);
console.log(result.valid); // whole-form validity after this run
console.log(result.errors); // full current error map after the runSchema integration:
import { createForm, schemaValidator } from '@vielzeug/formit';
import { v } from '@vielzeug/validit';
const schema = v.object({
age: v.number().min(18, 'Must be 18+'),
email: v.string().email('Invalid email'),
});
const form = createForm({
defaultValues: { age: 0, email: '' },
validator: schemaValidator(schema),
});Submission
const result = await form.submit(async (values) => {
await fetch('/api/submit', {
body: JSON.stringify(values),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
});
});
if (!result.ok && result.type === 'validation') {
console.log(result.errors);
}
const second = await form.submit(async () => {});
if (!second.ok && second.type === 'concurrent') {
console.log('Submission already in progress');
}Submit always touches all known fields and runs full validation before calling the handler. Validation failures resolve to { ok: false, 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);
});
// subscriptions fire synchronously; use sync:true when you also want the current snapshot immediately
form.subscribeField('email', () => {}, { sync: true });
stopEmail();
stopForm();
// Value-only pattern via subscribeField
const stopPreview = form.subscribeField('email', (field) => updatePreview(String(field.value ?? '')), { sync: true });
stopPreview();Snapshot semantics:
form.stateandform.field(name)return stable, frozen snapshots.- Reference identity stays the same until a relevant mutation occurs.
- This makes subscriptions directly compatible with external-store patterns.
Framework Integration
import { useSyncExternalStore } from 'react';
import { createForm, type Form } from '@vielzeug/formit';
// Reusable hooks
function useFormState<T extends Record<string, unknown>>(form: Form<T>) {
return useSyncExternalStore(
(notify) => form.subscribe(() => notify()),
() => form.state,
);
}
function useField<T extends Record<string, unknown>, K extends string & keyof T>(form: Form<T>, name: K) {
return useSyncExternalStore(
(notify) => form.subscribeField(name, () => notify()),
() => form.field(name),
);
}
// Component using the hooks
function LoginForm() {
const form = createForm({ defaultValues: { email: '', password: '' } });
const state = useFormState(form);
const emailField = useField(form, 'email');
return (
<form onSubmit={(e) => { e.preventDefault(); form.submit(async (v) => console.log(v)); }}>
<input value={emailField.value as string} onChange={(e) => form.set('email', e.target.value)} onBlur={() => form.touch('email')} />
{emailField.error && <p>{emailField.error}</p>}
<button type="submit" disabled={state.isSubmitting}>Submit</button>
</form>
);
}import { shallowRef, onScopeDispose } from 'vue';
import { createForm } from '@vielzeug/formit';
function useForm<T extends Record<string, unknown>>(defaultValues: T) {
const form = createForm({ defaultValues });
const state = shallowRef(form.state);
const stop = form.subscribe((snapshot) => { state.value = snapshot; });
onScopeDispose(() => stop());
return { form, state };
}<script lang="ts">
import { createForm } from '@vielzeug/formit';
const form = createForm({ defaultValues: { email: '', password: '' } });
const emailField = {
subscribe(run: (snapshot: ReturnType<typeof form.field>) => void) {
return form.subscribeField('email', run, { sync: true });
},
};
const formState = {
subscribe(run: (snapshot: typeof form.state) => void) {
return form.subscribe(run, { sync: true });
},
};
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
await form.submit(async (values) => console.log(values));
}
</script>
<form on:submit={handleSubmit}>
<input value={$emailField.value as string} on:input={(e) => form.set('email', (e.currentTarget as HTMLInputElement).value)} on:blur={() => form.touch('email')} />
<button type="submit" disabled={$formState.isSubmitting}>Submit</button>
</form>Pitfalls
- Forgetting cleanup/dispose calls can leak listeners or stale state.
- Skipping explicit typing can hide integration issues until runtime.
- Not handling error branches makes examples harder to adapt safely.
Touched and Error Controls
form.touch('email');
form.untouch('email');
form.touchAll();
form.untouchAll();
form.setError('email', 'Invalid email');
form.resetErrors({ email: 'Invalid email', password: 'Too short' });
form.setValidator('email', (value) => (!String(value).includes('@') ? 'Invalid email' : undefined));Bind
bind() is a vanilla-DOM convenience helper. In React, Vue, Svelte, and similar frameworks, prefer subscriptions plus explicit form.field(...), form.set(...), and form.touch(...) calls.
const binding = form.bind('email');
input.value = String(binding.value ?? '');
input.onblur = binding.onBlur;
input.oninput = (event) => {
binding.onChange((event.target as HTMLInputElement).value);
};
const fileForm = createForm({ defaultValues: { attachment: null as File | null } });
const fileBinding = fileForm.bind('attachment', {
touchOnBlur: true,
validateOnBlur: true,
});
fileInput.onchange = (event) => {
fileBinding.onChange((event.target as HTMLInputElement).files?.[0] ?? null);
};Global bind defaults via mode:
// mode pre-populates bindDefaults for all bind() calls
const form = createForm({
mode: 'onBlur', // 'onSubmit' | 'onBlur' | 'onChange' | 'onTouched'
defaultValues: { email: '' },
validators: { email: (v) => (!String(v).includes('@') ? 'Invalid email' : undefined) },
});
// No per-field config needed — validateOnBlur is inherited from mode
const email = form.bind('email');Explicit bindDefaults always takes precedence over mode.
For grouped synchronous updates, wrap writes in form.batch(() => { ... }) so subscribers observe one consolidated notification.
Global bind defaults via bindDefaults (for fine-grained control):
const formWithDefaults = createForm({
bindDefaults: { touchOnBlur: true, validateOnBlur: true },
defaultValues: { email: '' },
validators: { email: (v) => (!String(v).includes('@') ? 'Invalid email' : undefined) },
});
const email = formWithDefaults.bind('email');Reset, Replace, and Remove
const form = createForm({ defaultValues: { email: '', name: '' } });
form.reset();
form.replace({ email: 'guest@example.com', name: 'Guest' });
form.resetField('name');
// removeField: drops value, baseline, state, and validator entirely
form.removeField('name'); // use for conditional fields that are unmountedWhen loading server data as the new source of truth, prefer replace(values) so subsequent reset() uses that new baseline.
Arrays and Files
const form = createForm({ defaultValues: { tags: ['a'] } });
// All 7 array helpers
form.array('tags').append('z'); // ['a', 'z']
form.array('tags').prepend('first'); // ['first', 'a', 'z']
form.array('tags').insert(1, 'mid'); // ['first', 'mid', 'a', 'z']
form.array('tags').swap(0, 2); // ['a', 'mid', 'first', 'z']
form.array('tags').replace(1, 'new'); // ['a', 'new', 'first', 'z']
form.array('tags').remove(3); // ['a', 'new', 'first']
form.array('tags').move(0, 2); // ['new', 'first', 'a']
const fd = toFormData(form.values());toFormData is optimized for browser submit APIs and multipart uploads. When you inspect multipart entries later, browsers may surface Blob parts as File instances.
Working with Other Vielzeug Libraries
With Validit
Use schemaValidator() to keep one shared schema for runtime validation and static type inference.
import { createForm, schemaValidator } from '@vielzeug/formit';
import { v } from '@vielzeug/validit';
const schema = v.object({
email: v.string().email(),
password: v.string().min(8),
});
const form = createForm({
defaultValues: { email: '', password: '' },
validator: schemaValidator(schema),
});Best Practices
- Keep validators pure and deterministic.
- Use
mode: 'onBlur'ormode: 'onTouched'for most user-facing forms; reservemode: 'onChange'for real-time search or filter forms. - Prefer
subscribeField(name, ...)oversubscribe(...)for field-level rendering. - Use
replace(values)after loading server data to set a new baseline. - Use
removeField(name)when unmounting conditional fields so their state does not leak into validation. - Use the
SubmitResultreturned fromsubmit()when you want to handle validation failures without exceptions. - Call
dispose()when the form lifecycle ends.
Connect a Schema Validator
Use schemaValidator() to integrate any safeParse-compatible schema (e.g. Validit, Zod):
import { v } from '@vielzeug/validit';
import { createForm, schemaValidator } from '@vielzeug/formit';
const schema = v.object({
email: v.string().email('Invalid email'),
password: v.string().min(8, 'Min 8 characters'),
age: v.number().min(18, 'Must be 18 or older'),
});
const form = createForm({
defaultValues: { email: '', password: '', age: 0 },
validator: schemaValidator(schema),
});
const result = await form.submit(async (values) => {
await fetch('/api/register', { method: 'POST', body: JSON.stringify(values) });
});
if (!result.ok && result.type === 'validation') console.log(result.errors); // { email: '...', age: '...' }Stable Subscription Patterns
// ✅ React: useSyncExternalStore
const state = useSyncExternalStore(
(notify) => form.subscribe(() => notify()),
() => form.state,
);
// ✅ Vue/Svelte: subscribe + cleanup in lifecycle hooks
const stop = form.subscribe((snapshot) => { state.value = snapshot; });
onUnmounted(stop);
// ❌ subscribe without cleanup — leaks listeners
form.subscribe(() => { /* ... */ });Use bind() Only for Vanilla DOM
// ✅ Plain DOM / non-reactive usage
const emailBinding = form.bind('email');
input.value = String(emailBinding.value ?? '');
input.addEventListener('blur', emailBinding.onBlur);
input.addEventListener('change', (e) => emailBinding.onChange(e.target.value));
// ✅ In frameworks, prefer explicit reactive reads/writes
<input
value={String(form.field('email').value ?? '')}
onChange={(e) => form.set('email', e.target.value)}
onBlur={() => form.touch('email')}
/>Show Errors Only After Touch
// ✅ Only show after user interaction
{form.field('email').touched && state.errors['email'] && (
<span>{state.errors['email']}</span>
)}
// ❌ Shows errors on page load before user types anything
{state.errors['email'] && <span>{state.errors['email']}</span>}