About falcondev-form
A framework-agnostic reactive form-state library built on `@vue/reactivity`, with React and Vue bindings. It uses Standard Schema validation and lazy field accessors for typed, nullable form data.
Key features
- 01Standard Schema validation
- 02Reactive form and field state
- 03Lazy typed field accessors
- 04Nullable editing and validated submission
- 05React and Vue bindings
Use cases
- 01Building schema-validated React forms
- 02Building schema-validated Vue forms
- 03Managing nested and array fields
- 04Handling nullable edits before submission
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ falcondev-oss/ skills --skill falcondev-formAfter installing, type/falcondev-formin Claude Code to confirm it loaded.
Bundled filesreference/frameworks.mdreference/patterns.md
Details
From the project README@falcondev-oss/form
A framework-agnostic form-state library on top of @vue/reactivity. You give it a Standard Schema (zod v4, arktype, …) and reactive sourceValues; it gives you a reactive form handle with a tree of field accessors.
Three published packages — pick the one for the runtime, never import -core directly in app code:
| Package | Entry | Adds |
|---|---|---|
@falcondev-oss/form-core | useFormCore, all types, /reactive helpers | engine; use directly only outside React/Vue |
@falcondev-oss/form-react | useForm, useField, FormFieldMemo | React re-render integration, field.model = {value,onUpdate} |
@falcondev-oss/form-vue | useForm, useFormHandles | field.model writable computed (v-model) |
useForm from the framework package wraps useFormCore — same options, same handle. Everything below is identical across frameworks except field binding and re-render (see reference/frameworks.md).
Two ideas that explain everything
-
Accessors are lazy paths;
$use()materializes.form.fields.address.cityis just a typed path proxy — no field object exists yet. Calling.$use()creates (and caches) the actual reactiveFormField. Navigate with dot/.at(), then.$use()at the leaf you bind. -
Form data is
NullableDeep. While editing, every value can benull— a half-filled form has nulls everywhere. Soform.data,field.value, and accessor types are the schema's input type made deeply nullable (objects →T | null, arrays →(T|null)[] | null). The validated output type (non-null) only appears insubmit({ values }). Write UI against nullable types; trustsubmitfor clean data.
Golden path
import { useForm } from '@falcondev-oss/form-vue' // or -react
import z from 'zod'
const form = useForm({
schema: z.object({ name: z.string(), age: z.number() }),
sourceValues, // initial data (NullableDeep); a value, getter, or ref — React needs a stable reference
async submit({ values }) {
// `values` is validated OUTPUT: { name: string, age: number }
await api.save(values)
// return nothing → { success: true }; return { success: false } to keep form dirty
},
})
const nameField = form.fields.name.$use()
nameField.handleChange('Jane') // programmatic write (validates, marks dirty)
// or write straight through the reactive data:
form.data.name = 'Jane' // equivalent effect
await form.submit() // validates whole schema, runs submit if valid
useForm(options)
schema— any Standard Schema that also exposes Standard JSON Schema (zod v4, arktype). JSON Schema drivesfield.schemametadata and required-detection. Non-JSON-representable types (Date, bigint, Map…) need a codec/transform or metadata extraction silently degrades (a console warning fires).sourceValues— initialNullableDeepdata. A value, a getter() => data, or (Vue) aref. In React it must be a stable reference — see below. May beundefined→ form is pending (isLoading/field.isPendingtrue,dataisundefined, writes ignored) until it resolves. When it later changes: if the form is not dirty, the form resets to the new values; if dirty, the update is skipped with a warning (except during submit).submit({ values })—async, receives the validated output. Returnvoid/{success:true}→ form marked pristine;{success:false}→ stays dirty.awaiteverything that must finish before the form settles — most importantly cache/query invalidations, so fresh data flows back intosourceValuesbefore the form is marked pristine (an un-awaited invalidation lets the form settle against stale data). With TanStack Query:await queryClient.invalidateQueries(...)directly insubmit, or do it in the mutation'sonSuccessandawait mutateAsync(...)insubmit(awaiting the mutation awaitsonSuccess).disabled?—boolean | Ref | getter. BlockshandleChange/handleBlur/reseton all fields.hooks?— lifecycle hooks (seereference/patterns.md).
Writing sourceValues (do it this way)
Three ordered branches: still-loading → existing entity → blank defaults. Vue takes them as a getter; React takes them as a useMemoed value.
// Vue — getter, tracked reactively
useForm({
schema,
sourceValues: () => {
if (isLoading.value) return undefined // 1. source still loading → form is pending
if (entity.value) return entity.value // 2. edit: return the whole entity object
return { name: null, age: null } // 3. create: blank defaults
},
async submit({ values }) { /* … */ },
})
// React — same branches, wrapped in useMemo
const sourceValues = useMemo(() => {
if (isLoading) return undefined
if (entity) return entity
return { name: null, age: null }
}, [isLoading, entity])
useForm({ schema, sourceValues, async submit({ values }) { /* … */ } })
In React sourceValues must be a stable reference. Never inline the object (or a getter) in the useForm call: React re-runs the component body every render, so a fresh object identity re-seeds the form on every render — resetting a clean form and warning on a dirty one. Memoize it (useMemo), or hoist a constant to module scope, or pass query data directly (sourceValues: query.data — already stable). Getters are worse than useless here: the adapter captures the getter once on mount, so one built over React state or props is frozen at its first-render closure and never sees an update.
- Return
undefinedwhile the source is loading (e.g. the fetch for the entity being edited is pending). This puts the form in the pending state instead of seeding it with a wrong/empty shape you'd have to overwrite later. - Return the existing entity as one whole object — don't hand-build
{ name: entity?.name, age: entity?.age, … }with optional chaining per key. If the entity doesn't match the schema, spread and override just the divergent fields:return { ...entity, tags: entity.tags ?? [] }. - Use
nullfor fields with no meaningful default — not''or0just because the type is string/number.NullableDeepmakesnullvalid for every field; an empty string is a real value that reads as "the user typed nothing", which is different from "untouched".
The form handle
| Member | Type | Notes |
|---|---|---|
form.data | NullableDeep<T> | undefined | reactive; read and write directly (form.data.x = …). undefined while pending. |
form.fields | accessor tree | navigate then .$use() |
form.isDirty | boolean | any change made (edit count ≠ 0), reset by submit success / reset |
form.isChanged | boolean | deep-equals current data vs sourceValues (false if edited back to original) |
form.isLoading | boolean | submitting or pending source values |
form.isDisabled | boolean | loading or disabled option |
form.errors | Issue[] | undefined | all validation issues, or undefined |
form.submit() | () => Promise<{success:boolean}> | validates all, runs submit |
form.reset() | () => void | restore to sourceValues |
form.hooks | hookable | hook/hookOnce/addHooks |
Field accessors
form.fields.user.email.$use() // nested object
form.fields.tags.at(0).$use() // array item (negative index ok: .at(-1))
form.fields.tags.$use() // the array field itself
for (const item of form.fields.tags) item.$use() // iterate (reactive)
form.fields.tags.delete(field.key) // remove array item by its key (see below)
form.fields.$use() // root field = whole form value
form.fields['a.b'].$use() // keys containing dots work (auto-escaped)
.at(i) accessor exists even before the array/index does — .$use().value is null until data arrives. .delete(key) must target an array item's key (items[2]), not a nested property — otherwise it throws.
The FormField (result of $use())
| Member | Notes |
|---|---|
field.value | readonly ref of the field's NullableDeep value. Nested object props are still writable (writes flow to form.data); arrays can be .pushed. |
field.handleChange(v) | set value, validate, mark dirty, fire field-change hooks |
field.handleBlur() | validate if the field was edited (use on input blur) |
field.reset() | restore this field to its source value |
field.errors | string[] | undefined — messages for this field (and nested unclaimed issues) |
field.schema | SchemaMeta — required, title, min/maxLength, minimum/maximum, … from the schema (see patterns) |
field.disabled / field.isPending | mirror form state |
field.isDirty / field.isChanged | per-field, same semantics as form |
field.path | dot/bracket path string |
field.key | stable unique id (path@timestamp-rand); use as list :key and for array.delete() |
field.model | framework binding — Vue: writable computed (v-model); React: { value, onUpdate }. See frameworks. |
field.$() | get the accessor tree from a field (to reach children of a $used field) |
Two write paths (equivalent)
field.handleChange(newValue)— explicit, what input handlers call.form.data.path = newValue— direct reactive mutation;on-changeobserves it and triggers the same validation.
Both mark the form dirty and re-validate. Use handleChange/model in components; direct writes are handy in tests and effects.
When validation runs
Whole-schema validation (not per-field), then issues are filtered to each field's path:
- on submit — always;
- on change — only if the field already has errors (so fixing an error clears it live);
- on blur — if the field was edited.
Errors clear while isLoading. A field also surfaces validation issues of nested paths that haven't been $used yet.
Gotchas
- Read/write nullable, submit non-null. Don't assume
field.valueis non-null in the UI. $use()is required to bind. Accessors alone are inert paths. In React,$use()re-renders theuseForm-owning component; a child reading a field prop needsuseField(field)orFormFieldMemoto re-render (see frameworks).sourceValueswon't overwrite a dirty form (by design) — reset first if you need to force it.delete(key)needs an array-item key, andfield.keychanges when the array is replaced viahandleChange(cache is cleared).- Don't import from
-corein a React/Vue app — you lose re-render/modelintegration.
Deeper reference
- Framework specifics (React re-render/
useField/FormFieldMemo, Vuev-model/useFormHandles, using core standalone) → readreference/frameworks.md. - Optional features — discriminated unions (
$use({discriminator})), value translation ($use({translate})),field.schemametadata, and hooks → readreference/patterns.md.