useForm()
Overview
declare namespace FormController {
export type Configs<TOutput extends object> = {
initialData?: DeepPartial<TOutput>;
validationSchema?: StandardSchemaV1<unknown, TOutput>;
equalityComparators?: Map<any, (a: any, b: any) => boolean>;
};
}
export function useForm<TOutput extends object>(
formConfigs: FormController.Configs<TOutput>,
hookConfigs?: {
validateMode?: "onChange" | "onBlur" | "onSubmit";
revalidateMode?: "onChange" | "onBlur" | "onSubmit";
},
);
- TOutputrepresents the final validated shape of your form data. It defines the full object structure that the form is expected to produce after successful validation and submission.
useForm() is a thin React binding around the headless FormController (form.controller).
It creates the controller once, keeps it stable for the component's lifetime, and exposes a set of opt-in watcher hooks that allow React components to subscribe to specific form mutations.
At runtime, useForm():
- Instantiates a single
FormControllerand preserves it viauseState - Subscribes React only to submission lifecycle changes (
"submissionStatusChange"event) - Exposes the
controllerdirectly for imperative and declarative operations - Provides scoped watcher hooks (
watchValues(),watchIssues(),watchEvent()) to opt into reactivity where needed - Internally uses
useSyncExternalStore()to safely bridge thecontroller's event system into React without breaking concurrent rendering guarantees
Configurations
FormController constructor.
It defines the initial data, validation schema, and equality comparators for the form."onChange": Validate on every value change (default)"onBlur": Validate when a field loses focus"onSubmit": Validate only on form submission
"onChange": Revalidate on every value change (default)"onBlur": Revalidate when a field loses focus"onSubmit": Revalidate only on form submission
Type Reference
useForm() returns an object with the following shape:
export interface UseForm<TOutput extends object> {
formConfigs: FormController.Configs<TOutput>;
hookConfigs: {
validateMode: "onChange" | "onBlur" | "onSubmit";
revalidateMode: "onChange" | "onBlur" | "onSubmit";
};
controller: FormController<TOutput>;
path: FieldPathBuilder<TOutput>;
watchValues: () => DeepReadonly<DeepPartial<TOutput>>;
watchIssues: () => StandardSchemaV1.Issue[];
watchEvent: (eventName: string, listener?: (payload: any) => void) => void;
}
import { type UseForm } from "@goodie-forms/react";
// ^ Library exports UseForm for easy typing in your use cases
interface MyProps {
form: UseForm<MyFormData>;
// ^ Such as a child component that receives the form as a prop
}
The Key Design Principle: Explicit Reactivity
useForm() does not behave like a traditional "form state hook". There is:
- ❌ No automatic re-render on every keystroke
- ❌ No large object diffing inside React
- ❌ No duplicated state between React and the controller
Instead, you explicitly decide where React should subscribe to the form.
@goodie-forms/react exposes multiple granular hooks and components so you can bind React to exactly the slice of state you need:
- 📦
<FieldRenderer />: A declarative reactive boundary that automatically subscribes to the field it renders. This is the preferred way to build your forms. - ⚓
useFormField(): Establishes a scoped connection to a specific field, giving you thefieldAPI without subscribing to unrelated changes. - ⚓
useFieldValue(): Reactively tracks only that field's value — nothing else in the form can trigger a re-render. - ⚓
useFieldIssues(): Reactively tracks validation state for a single field. - 👁🗨
form.watchValues(): Re-renders the calling component when any form value changes. Useful for previews, summaries, or debugging views. - 👁🗨
form.watchIssues(): Re-renders when validation issues change. Ideal for global error banners or submit-state UI. - 👁🗨
form.watchEvent(): Lets you subscribe to any internal controller event with full control.
This model keeps React renders surgically precise:
- Field components update independently from each other
- Large forms avoid cascading renders
- React only observes state you explicitly opt into
- The
FormControllerremains the single, framework-agnostic source of truth
In other words, React is used as a view subscription layer, not as the form state container.
Watchers
👁🗨 watchValues()
const values = form.watchValues();
// ^? DeepReadonly<DeepPartial<TOutput>>
Subscribes the calling component to all form value changes.
- Triggers a re-render whenever any field value is updated
- Returns the current full form data object (
TOutput) - Uses
useSyncExternalStore()internally for concurrent-safe subscriptions - Does not subscribe to validation or submission state
Use when:
- Rendering a live preview of the form
- Building value-driven UI outside of individual fields
- Inspecting the entire form state
👁🗨 watchIssues()
const issues = form.watchIssues();
// ^? StandardSchemaV1.Issue[]
Subscribes the calling component to validation issue changes across the form.
- Triggers a re-render whenever any field's issues are updated
- Returns the controller's issue list
- Independent from value updates — changing a value alone will not re-render unless it affects validation
Use when:
- Rendering error summaries
- Disabling/enabling submit UI based on validity
- Showing global validation feedback
👁🗨 watchEvent()
form.watchEvent(eventName, listener?);
Subscribes to a specific internal FormController event.
- Re-renders the component whenever the given event fires
- Optionally invokes a listener with the event payload
- Works with any event exposed by
form.controller.events - Provides the lowest-level reactive hook for custom integrations
Use when:
- You need fine-grained control over when React updates
- Integrating with non-standard workflows
- Reacting to lifecycle events not covered by
watchValues()/watchIssues()
Examples
📚 Basic Form Setup
import { useForm } from "@goodie-forms/react";
import z from "zod";
const LoginFormSchema = z.object({
email: z.string(),
password: z.string(),
});
type LoginForm = z.infer<typeof LoginFormSchema>;
export function Login() {
const form = useForm({
validationSchema: LoginFormSchema,
initialData: { email: "", password: "" },
});
const handleSubmit = form.controller.createSubmitHandler(
async (data) => {
console.log("Submitted", data);
},
async (issues) => {
console.error("Failed submission with issues", issues);
},
);
return (
<form onSubmit={handleSubmit}>
{/* Fields rendered elsewhere (e.g. FieldRenderer) */}
<button type="submit">Submit</button>
</form>
);
}
📚 Watching All Values
import { type UseForm } from "@goodie-forms/react";
function LivePreview({ form }: { form: UseForm<LoginForm> }) {
const values = form.watchValues();
return <pre>{JSON.stringify(values, null, 2)}</pre>;
}
📚 Watching Validation Issues
import { type UseForm } from "@goodie-forms/react";
function ErrorSummary({ form }: { form: UseForm<LoginForm> }) {
const issues = form.watchIssues();
const hasErrors = issues.length > 0;
if (!hasErrors) return null;
return <div>Please fix the highlighted errors.</div>;
}
📚 Analytics / Telemetry Hook
import { type UseForm } from "@goodie-forms/react";
function FormAnalytics({ form }: { form: UseForm<LoginForm> }) {
form.watchEvent("fieldValueChanged", (path) => {
// Lightweight event tap — no rerender logic needed
analytics.track("form_field_changed", { path });
});
return null;
}
📚 Syncing With External State
import { type UseForm } from "@goodie-forms/react";
function SyncToStore({
form,
setDraft,
}: {
form: UseForm<LoginForm>;
setDraft: (data: LoginForm) => void;
}) {
form.watchEvent("fieldValueChanged");
const values = form.controller.data;
useEffect(() => {
setDraft(values);
}, [values, setDraft]);
return null;
}