@jsvision/forms / createForm
Function: createForm()
createForm<
S,I>(options):Form<S,I>
Defined in: create-form.ts:91
Create a headless, reactive form store over a Zod object schema.
The store owns one raw editing signal per field (bind widgets straight to field(name).value), validates the whole object through schema.safeParse in one memoized computed, and exposes per-field and form-level accessors plus submit / reset. Opt into per-field async checks with asyncValidators. It draws nothing.
Gotchas: initial holds the raw editing values, so a z.coerce.number() field is initialised as a string (e.g. port: '8080'); values() returns the coerced object only when the form is valid, otherwise null. A form that mounts asyncValidators should be dispose()d when it is no longer needed (e.g. a per-dialog form) so no debounce fires after teardown; a long-lived app-level form can leave it.
Type Parameters
S
S extends ZodObject<Readonly<{[k: string]: $ZodType<unknown, unknown, $ZodTypeInternals<unknown, unknown>>; }>, $strip>
I
I extends Record<keyof output<S>, unknown>
Parameters
options
CreateFormOptions<S, I>
the schema, the raw initial values, and optional asyncValidators / asyncDebounceMs.
Returns
Form<S, I>
a Form store.
Example
import { createForm } from '@jsvision/forms';
import { z } from 'zod';
const schema = z.object({
name: z.string().min(1, 'Required'),
port: z.coerce.number().int().min(1).max(65535),
});
// `initial` holds the RAW editing values (port edited as a string):
const form = createForm({
schema,
initial: { name: '', port: '8080' },
asyncValidators: {
// Runs debounced, only while the field is sync-clean. Catch your own I/O errors —
// an uncaught rejection is treated as "no async error".
name: async (value, { signal }) => {
try {
const res = await fetch(`/api/available?u=${encodeURIComponent(value)}`, { signal });
return (await res.json()).taken ? 'Already in use' : null;
} catch {
return 'Could not verify';
}
},
},
asyncDebounceMs: 300,
});
form.field('name').value.set('db');
form.field('name').validating(); // true while the check is in flight
form.field('name').asyncError(); // 'Already in use' | null (distinct from error())
form.values(); // { name: 'db', port: 8080 } — port coerced to a number
await form.submit((values) => {
console.log(values.port); // 8080 (typed as a number)
});
// Open-to-edit: load an existing record. `loading()` drives the "Loading…" swap. On success every
// field value AND the whole baseline rebase to the loaded record, so the form is pristine.
const ok = await form.load(async ({ signal }) => {
const res = await fetch('/api/servers/42', { signal });
const s = await res.json();
return { name: s.name, port: String(s.port) }; // the RAW editing shape (port edited as a string)
});
if (!ok) console.log('Could not load'); // loader rejected → false, state untouched
form.dirty(); // false — the loaded record is the new baseline
form.reset(); // returns to the LOADED record, not the blank initial
form.dispose(); // tear down the async effects + abort any in-flight load (per-dialog forms must call this)