← Files WixARCHIVED FILE
skills/wix-headless-templates/forms/project/src/hooks/forms/useWixForm.ts
4.73 KB · Oct 8, 2026 · 12:02 UTC
// React binding of the form store (wix/forms/form-store.ts) — schema in, validated submission
// out, minus the markup; the state machine lives there, framework-free. This hook subscribes to
// one store per mounted form and exposes its state and actions under one name, plus `bind` (the
// props a text-ish control spreads). Astro islands and React SPAs use this; a static page, Vue,
// or Svelte uses the store directly.
//
// This vertical ships no components: a form is schema-driven (the owner picks the fields), so
// every form has a different field set and no "contact form" component could ship for it. You
// render `form.fields`; see templates/forms/INSTRUCTIONS.md.
//
// SSR-friendly: pass a server-fetched FormDto as `initialForm` (Astro frontmatter) and no
// client fetch happens; a SPA passes nothing and the hook loads it.
import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
import { createFormStore, type FormStore, type FormSubmitEvent } from "../../wix/forms/form-store";
import type { FormDto, FormErrors, FormValues, SubmitOutcome } from "../../wix/forms/types";
export { FORM_ERROR, otherText, otherValue, validateValue } from "../../wix/forms/form-store";
export interface UseWixFormOptions {
/** Server-fetched form (Astro frontmatter) — skips the client fetch entirely. */
initialForm?: FormDto;
}
export interface UseWixForm {
/**
* null while the schema is loading — render a skeleton, not an empty form. Loaded: the owner's
* rules are applied to the current values, so `form.fields` is exactly what to render now.
*/
form: FormDto | null;
/** `target` → current value. Arrays for multi-choice and files, objects for an address. */
values: FormValues;
setValues: (next: FormValues | ((prev: FormValues) => FormValues)) => void;
/** Props for a text-ish control, ready to spread: `<input {...bind("email_a1")} />`. */
bind: (target: string) => {
name: string;
value: string;
onChange: (e: { target: { value: string } }) => void;
onBlur: () => void;
"aria-describedby": string;
"aria-invalid": true | undefined;
};
/** `onSubmit`. Resolves the outcome when the submission was created — that IS the success signal; false when it did not send. */
submit: (event?: FormSubmitEvent) => Promise<SubmitOutcome | false>;
/** One field, one address subfield, or the whole form when called with nothing. */
validate: (target?: string) => boolean;
errors: FormErrors;
/** Loading the schema, or submitting. */
loading: boolean;
/** Multi-step: the index into `form.steps` being shown, and the moves. `next` validates the current step first. */
step: number;
next: (event?: FormSubmitEvent) => boolean;
previous: () => void;
goToStep: (index: number) => void;
/** The form is switched off or past its deadline: render `form.disabledMessage` instead of the fields. */
closed: boolean;
/** The last successful submit (render the thank-you / navigate to `url`), until `reset()` or the owner's auto-hide. */
outcome: SubmitOutcome | null;
reset: () => void;
/** Hand a captcha widget's token to the next submit. */
setCaptchaToken: (token: string | null) => void;
}
export function useWixForm(formId: string, options: UseWixFormOptions = {}): UseWixForm {
const { initialForm } = options;
const ref = useRef<{ formId: string; store: FormStore } | null>(null);
if (!ref.current || ref.current.formId !== formId) ref.current = { formId, store: createFormStore({ formId, initialForm }) };
const store = ref.current.store;
useEffect(() => {
store.start();
return () => store.stop();
}, [store]);
const state = useSyncExternalStore(store.subscribe, store.getState, store.getState);
const { values, errors } = state;
// Covers input / textarea / select. A checkbox or radio group carries `checked` instead of
// `value`, and a file input cannot be controlled at all — wire those by hand, keeping the
// same `name`, `onBlur` and aria contract.
const bind = useCallback(
(target: string) => ({
name: target,
value: String(values[target] ?? ""),
onChange: (e: { target: { value: string } }) => store.setValue(target, e.target.value),
onBlur: () => store.validate(target),
"aria-describedby": `err-${target}`,
"aria-invalid": errors[target] ? (true as const) : undefined,
}),
[values, errors, store],
);
return {
form: state.form,
values,
setValues: store.setValues,
bind,
submit: store.submit,
validate: store.validate,
errors,
loading: state.loading,
step: state.step,
next: store.next,
previous: store.previous,
goToStep: store.goToStep,
closed: state.closed,
outcome: state.outcome,
reset: store.reset,
setCaptchaToken: store.setCaptchaToken,
};
}
SHA-256: 3e3da74bb3ae801d442bd68ad5cd99eba3ccaf4513d3c5dee71c4c3c4b3ae248