Skip to content

@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

ts
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)