Tutorial
This tutorial will guide you through building a fully controlled React signup form using the @goodie-forms/react bindings. By the end, you'll understand:
- How to set up a form with
useForm. - How to create fields with
useFormFieldandFieldRenderer. - How validation works with
zodschemas (or any other Standard Schema schemas). - How to handle nested objects and dynamic arrays in forms.
We'll build a User Signup form that covers names, address, friends, scores, and an inventory system.
1. Defining the Data Shape
We start by defining the form data structure.
UserForm contains basic user info, nested objects, and optional custom classes:
class Inventory {
contents: string[] = [];
push(item: string) {
this.contents.push(item);
}
}
interface UserForm {
name: string;
surname: string;
address: { city: string; street: string };
badges: string[];
friends: { name: string; friendshipPoints: number }[];
inventory?: Inventory;
}
Inventory is a custom class — @goodie-forms can handle classes with custom validation.2. Adding Validation with Zod
We can enforce strong validation using zod (or any other Standard Schema-compatible library).
See the Validation page for more information on how validation works and how to integrate with other libraries.
const UserSchema = z.object({
name: z.string().nonempty(),
surname: z.string().nonempty(),
address: z.object({ city: z.string(), street: z.string() }),
friends: z.array(
z.object({
name: z.string(),
friendshipPoints: z.number(),
}),
),
badges: z.array(z.string()),
inventory: z
.custom<Inventory>((d) => d instanceof Inventory, "Invalid inventory")
.superRefine((d, ctx) => {
if (d.contents.length % 2 !== 0) {
ctx.addIssue({
code: "custom",
message: "Requires an even amount of items",
});
}
d.contents.forEach((item, i) => {
if (item.length < 2) {
ctx.addIssue({
code: "custom",
message: "Item must be at least 2 characters long",
path: ["contents", i],
});
}
});
})
.optional(),
});
⭐ Bonus: Infer data type from Schema
Generally if you have a validation schema available, it is more convinient to infer data type from the schema rather than manually declaring. Most of the validation libraries provide a helper type to infer data type.
Here's how you do that with zod:
// Infer type from the Schema instead of manually declaring
type UserForm = z.infer<typeof UserSchema>;
3. Setting Up the Form Controller
We use useForm to create a form controller. It manages state, validation, and submission:
const form = useForm(
{
validationSchema: UserSchema,
},
{
validateMode: "onBlur",
revalidateMode: "onChange",
},
);
Options:
validateMode: when fields initially validate (onChange,onBlur,onSubmit).revalidateMode: when fields validate, after the first submission attempt.
useForm() is a reactive hook that re-renders the component when the form state (such as form.controller.isSubmitting) changes.
You can take advantage of this behavior to render indicators that reflect the submission status.useForm() does not react to individual field value changes (this can be configured using the watchValues option).
This ensures your component does not re-render unnecessarily.4. Rendering Fields
Use FieldRenderer to register and render fields declaratively.
<FieldRenderer
form={form}
path={form.path.of("name")}
defaultValue={""}
render={({ fieldProps, field, form }) => (
<div>
<label htmlFor="name">Name</label>
<input
{...fieldProps}
id="name"
type="text"
disabled={form.controller.isSubmitting}
/>
{field.issues && <span>{field.issues.at(0)?.message}</span>}
</div>
)}
/>;
⭐ Reducing Boilerplate
Rewriting a similar render function for every FieldRenderer can be exhaustive and redundant.
There are ways to standardize and reduce the boilerplating it requires.
Some external UI libraries provide a component that encapsulates a single field with a label, possible error text and etc.
Such as Base UI's <Field/> compound component (See their docs)
These kinds of libraries can be easily used under FieldRenderer's render prop.
<FieldRenderer
form={form}
path={form.path.of("name")}
defaultValue={""}
render={({ fieldProps, field, form }) => (
<Field.Root>
<Field.Label>Name</Field.Label>
<input
{...fieldProps}
id="name"
type="text"
disabled={form.controller.isSubmitting}
/>
<Field.Error match={field.issues.length !== 0}>
{field.issues.at(0)?.message}
</Field.Error>
</Field.Root>
)}
/>;
Rewriting a whole set of components to render labels, error indicators etc. can be exhausting and redundantly repetitive.
To solve that and standardize how a field looks throughout your application, you can craft a new component by wrapping FieldRenderer.
import type { FieldPath } from "@goodie-forms/core";
import {
FieldRenderer,
useFormField,
type FieldRendererProps,
} from "@goodie-forms/react";
import { useId } from "react";
type Props<
TOutput extends object,
TPath extends FieldPath.Segments,
> = FieldRendererProps<TOutput, TPath> & {
label: string;
};
export function MyField<
TOutput extends object,
const TPath extends FieldPath.Segments,
>(props: Props<TOutput, TPath>) {
const id = useId();
const field = useFormField(props.form, props.path);
const fieldError = field?.issues.at(0);
return (
<div className="flex flex-col gap-2 items-start">
<label
className={
field == null
? ""
: !field.isValid
? "text-red-400"
: field.isDirty
? "text-orange-300"
: field.isTouched
? "text-blue-200"
: ""
}
>
{props.label}
</label>
<FieldRenderer
{...props}
render={(renderParams) => {
return (
<div id={id} className="flex w-full *:w-full">
{props.render(renderParams)}
</div>
);
}}
/>
{fieldError && (
<span className="text-red-400 text-xs text-left">
{fieldError.message}
</span>
)}
</div>
);
}
It can then be used easily without repeating much:
<MyField
form={form}
path={form.path.of("name")}
label="User Name"
defaultValue="foo"
render={({ fieldProps, form }) => (
<input
{...fieldProps}
disabled={form.controller.isSubmitting}
type="text"
placeholder="John"
/>
)}
/>;
5. Creating Submit Handler
Goodie Forms provide an easy to use and
const handleSubmit = form.controller.createSubmitHandler<FormEvent>(
async (data) => {
const result = await api.registerUser(data);
if (result.status === 200) {
form.controller.reset(data); // Persist submitted data, if succeeded
}
},
async (issues) => {
console.warn("Form errors:", issues);
},
);
- First callback: successful submission, provides
dataand originalevent. - Second callback: validation errors, provides the validation
issues.
Once creating the submit handler, you can attach it to your <form> element.
<form onSubmit={handleSubmit}>
{/* fields */}
<button type="submit" disabled={form.controller.isSubmitting}>
Submit
</button>
</form>;
- Make it so submitting the form triggers validation automatically before calling your success callback.
- Prevent the default HTML form submission, so the page won't reload.
- Call the first callback if the form is valid, passing in the current form
dataand the originalevent. - Call the second callback if validation fails, passing in a list of validation
issuesfor you to handle or display. - Automatically set the form state to submitting during the
asyncoperation, which you can use to disable inputs or show a loading state. - Allow you to reset or persist the form
dataafter a successful submission usingform.controller.reset(data). - Integrate seamlessly with nested fields, dynamic arrays, and custom field types, so all changes are reflected in the submitted
data. - Ensure that any field-level or schema-level validations are respected before submission.
- This combination of behaviors makes handling complex forms safe, predictable, and reactive, automatically without boilerplate
onChangeoronBlurwiring.
6. Adding Utility Buttons
The form controller provides convinient helpers:
<button onClick={() => form.controller.reset()}>Reset</button>
<button onClick={() => form.controller.validateForm()}>Validate</button>
- Reset restores initial values or a new initial values.
- Validate triggers the schema validation manually without submitting.
7. Watching Fields, Values and Events
While FieldRenderer is perfect for binding inputs,
sometimes you need direct access to a field or want to react to specific value changes, issue changes or a specific event inside your component.
This is where useFormField(), useFieldValue() and useForm()'s watchers shine.
⚓ useFormField() hook
useFormField() gives you full programmatic control over a specific field.
const inventoryField = useFormField(form, form.path.of("inventory"), {
defaultValue: () => new Inventory(),
overrideInitialValue: true,
});
It returns a field instance (FormField) which exposes ways to directly interact with the field.
This is useful when:
- You need to update a field outside of an input.
- You want to trigger changes from buttons or side effects.
- You need fine-grained control over nested or optional fields.
⚓ useFieldValue() hook
If you only need the value — not full control — you can use useFieldValue().
const name = useFieldValue(form, form.path.of("name"));
This hook:
- Subscribes only to the given field path.
- Re-renders the component when that specific
valuechanges. - Avoids re-rendering on unrelated field updates.
⚓ useFieldIssues() hook
If you only need the issues — not full control — you can use useFieldIssues().
const nameIssues = useFieldIssues(form, form.path.of("name"));
This hook:
- Subscribes only to validation
issuesof the given field path. - Re-renders the component when that field's
issueschange. - Avoids re-rendering on unrelated value or form updates.
⚓ useForm() watchers
By default, useForm() does not re-render your component when arbitrary form values or validation issues change.
This is intentional — it keeps your components performant and avoids unnecessary re-renders.
However, in some scenarios, you may want your component to react to:
- Any change in form
dataviaform.watchValues() - Any change in validation
issuesviaform.watchIssues() - Any event emitted by the
form.controller.eventsviaform.watchEvent()
For those cases, useForm() exposes reactive watcher hooks:
const form = useForm({
validationSchema: UserSchema,
});
const values = form.watchValues();
const issues = form.watchIssues();
form.watchEvent("fieldTouchUpdated");
// ^ Triggers re-renders on a field's isTouched change
form.watchEvent("fieldDirtyUpdated", (path) => console.log(path));
// ^ A listener can also be attached
These functions behave like hooks:
- They subscribe to internal controller events.
- They trigger re-renders when the relevant state changes.
- They only affect the component that calls them.
- If you don't call them, no subscription is created.
👁🗨 watchValues() watcher
When enabled:
- The component re-renders whenever the form
datachanges. - You always receive the latest values.
- Useful for previews, dashboards, and debugging panels.
Example:
const form = useForm({
validationSchema: UserSchema,
});
const values = form.watchValues();
return (
<>
<RegistrationForm form={form} />
<pre>{JSON.stringify(values, null, 2)}</pre>
</>
);
If watchValues() is not called, the preview will not update.
This makes the subscription explicit and opt-in.
👁🗨 watchIssues() watcher
When enabled:
- The component re-renders whenever validation
issueschange. - You always receive the latest
issues. - Useful for global error summaries.
- Ideal for showing a "Form has errors" banner.
Example:
const form = useForm({
validationSchema: UserSchema,
});
const issues = form.watchIssues();
const hasErrors = issues.length > 0;
return hasErrors ? (
<div className="error-banner">Please fix the highlighted errors.</div>
) : null;
If watchIssues() is not called, the component will not react to validation changes.
👁🗨 watchEvent() watcher
When invoked:
- The component re-renders whenever the specified event fires.
- You can optionally provide a listener to execute custom logic when the event occurs.
- Designed as a low-level reactive primitive.
- Ideal for advanced scenarios and custom reactive behaviors.
Unlike form.watchValues() or form.watchIssues(), which are high-level convenience helpers,
form.watchEvent() gives you direct access to the form controller's event system.
const form = useForm({
validationSchema: UserSchema,
});
form.watchEvent("fieldTouchUpdated");
// ^ Triggers re-renders on a field's isTouched change
return <RegistrationForm form={form} />;
// You can pass in a listener callback too
form.watchEvent("validationStatusChange", (isValidating) => {
if (isValidating) {
console.log("Hold up, for is now validating...");
} else {
console.log("Validation is done");
}
});
📚 Example 1: Auto-Capitalize Name
const nameField = useFormField(form, form.path.of("name"));
useEffect(() => {
if (!nameField.value) return;
const normalized =
nameField.value.charAt(0).toUpperCase() +
nameField.value.slice(1).toLowerCase();
if (normalized !== nameField.value) {
nameField.setValue(normalized);
}
}, [nameField.value]);
📚 Example 2: Enable Inventory Only After Address Is Filled
Imagine your business rule is:
Users can only manage inventory after providing a valid city.
Instead of checking this inside each button, you can reactively control the UI:
const city = useFieldValue(form, form.path.of("address.city"));
const inventoryField = useFormField(form, form.path.of("inventory"));
<fieldset disabled={city === ""}>
<InventorySection form={form} />
</fieldset>;
📚 Example 3: Live Form Completion Indicator
A common real-world feature is showing progress:
const name = useFieldValue(form, form.path.of("name"));
const surname = useFieldValue(form, form.path.of("surname"));
const city = useFieldValue(form, form.path.of("address.city"));
const completedFields = [name, surname, city].filter(Boolean).length;
const progress = (completedFields / 3) * 100;
<div className="progress-bar">
<div style={{ width: `${progress}%` }} />
</div>;
📚 Example 4: Hint If Inventory Is Empty
Instead of relying purely on schema validation, you might want a soft UX warning:
const inventory = useFieldValue(form, form.path.of("inventory"));
const hasItems = inventory?.contents?.length > 0;
<>
{!hasItems && (
<p className="text-yellow-500 text-sm">You haven't added any items yet.</p>
)}
</>;
📚 Example 5: Cross-Field Derived Preview
const name = useFieldValue(form, form.path.of("name"));
const surname = useFieldValue(form, form.path.of("surname"));
<div className="preview-card">
<strong>Public Profile Preview</strong>
<p>
{name || "First"} {surname || "Last"}
</p>
</div>;
📚 Example 6: Disable Submit Until Form Is Valid
const form = useForm({
validationSchema: UserSchema,
});
const issues = form.watchIssues();
<button
type="submit"
disabled={form.controller.isSubmitting || issues.length > 0}
>
Submit
</button>;
📚 Example 7: Registration Summary Step
Imagine a 2-step registration process:
- Step 1: Fill the form
- Step 2: Review before final submission
const form = useForm({
validationSchema: UserSchema,
});
const values = form.watchValues();
const issues = form.watchIssues();
<div className="summary">
<h3>Review Your Information</h3>
<p>
<strong>Name:</strong> {values.name}
</p>
<p>
<strong>Surname:</strong> {values.surname}
</p>
<p>
<strong>City:</strong> {values.address?.city}
</p>
{issues.length > 0 && (
<div className="warning">Some fields need attention before submission.</div>
)}
</div>;
📚 Example 8: Multi-step Form Wizard
Since FieldRenderer won't destroy field on unmount by default, it is super trivial to build a multi-step form/wizard!
const RegistrationSchema = z.object({
account: z.object({
email: z.string().email("Invalid email"),
password: z.string().min(6, "Minimum 6 characters"),
}),
profile: z.object({
firstName: z.string().min(1, "Required"),
lastName: z.string().min(1, "Required"),
}),
preferences: z.object({
newsletter: z.boolean(),
}),
});
export function MultiStepRegistration() {
const form = useForm({
validationSchema: RegistrationSchema,
});
const [step, setStep] = useState(1);
// Example: preview subscription (reactive but isolated)
const email = useFieldValue(form, form.path.of("account.email"));
const firstName = useFieldValue(form, form.path.of("profile.firstName"));
const handleSubmit = form.controller.createSubmitHandler(async (data) => {
await sendToRemoteApiOrSomething(data);
alert("Registration complete!");
});
return (
<div className="max-w-xl mx-auto flex flex-col gap-6">
<h2>Step {step} of 3</h2>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
{/* ---------------- Step 1: Account ---------------- */}
{step === 1 && (
<>
<FieldRenderer
form={form}
path={form.path.of("account.email")}
defaultValue=""
render={({ fieldProps, field }) => (
<div className="flex flex-col gap-1">
<label>Email</label>
<input {...fieldProps} placeholder="john@example.com" />
{field.issues[0] && (
<span className="error">{field.issues[0].message}</span>
)}
</div>
)}
/>
<FieldRenderer
form={form}
path={form.path.of("account.password")}
defaultValue=""
render={({ fieldProps, field }) => (
<div className="flex flex-col gap-1">
<label>Password</label>
<input type="password" {...fieldProps} />
{field.issues[0] && (
<span className="error">{field.issues[0].message}</span>
)}
</div>
)}
/>
</>
)}
{/* ---------------- Step 2: Profile ---------------- */}
{step === 2 && (
<>
<FieldRenderer
form={form}
path={form.path.of("profile.firstName")}
defaultValue=""
render={({ fieldProps, field }) => (
<div className="flex flex-col gap-1">
<label>First Name</label>
<input {...fieldProps} />
{field.issues[0] && (
<span className="error">{field.issues[0].message}</span>
)}
</div>
)}
/>
<FieldRenderer
form={form}
path={form.path.of("profile.lastName")}
defaultValue=""
render={({ fieldProps, field }) => (
<div className="flex flex-col gap-1">
<label>Last Name</label>
<input {...fieldProps} />
{field.issues[0] && (
<span className="error">{field.issues[0].message}</span>
)}
</div>
)}
/>
</>
)}
{/* ---------------- Step 3: Preferences ---------------- */}
{step === 3 && (
<FieldRenderer
form={form}
path={form.path.of("preferences.newsletter")}
defaultValue={false}
render={({ fieldProps }) => (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={fieldProps.value}
onChange={(e) => fieldProps.onChange(e.target.checked)}
/>
Subscribe to newsletter
</label>
)}
/>
)}
{/* ---------------- Navigation ---------------- */}
<div className="flex justify-between mt-4">
{step > 1 && (
<button type="button" onClick={() => setStep((s) => s - 1)}>
Back
</button>
)}
{step < 3 && (
<button type="button" onClick={() => setStep((s) => s + 1)}>
Next
</button>
)}
{step === 3 && (
<button type="submit" disabled={form.controller.isSubmitting}>
{form.controller.isSubmitting ? "Submitting..." : "Finish"}
</button>
)}
</div>
</form>
{/* ---------------- Live Preview ---------------- */}
<div className="p-4 border rounded-xl">
<h3>Live Summary</h3>
<p>Email: {email || "—"}</p>
<p>First Name: {firstName || "—"}</p>
</div>
</div>
);
}
8. Putting It All Together
At this point, you've seen how each primitive solves a specific problem. Now let's look at how they work together to build a real, production-ready form flow:
import {
FieldRenderer,
useFieldIssues,
useFieldValue,
useForm,
useFormField,
} from "@goodie-forms/react";
import z from "zod";
// 1️⃣ Declare the form's validation rules
const UserSchema = z.object({
name: z.string().nonempty("Name is required"),
surname: z.string().nonempty("Surname is required"),
address: z.object({
city: z.string().min(1, "City is required"),
}),
});
// 2️⃣ Infer the form's shape by the validation schema
type UserForm = z.infer<typeof UserSchema>;
export function SignupForm() {
// 3️⃣ Create the form
const form = useForm<UserForm>(
{ validationSchema: UserSchema },
{
validateMode: "onBlur",
revalidateMode: "onChange",
},
);
// 4️⃣ Imperative access (used by buttons / business logic)
const cityField = useFormField(form, form.path.of("address.city"));
// 5️⃣ Reactive individual subscriptions (used to compute fullName)
const name = useFieldValue(form, form.path.of("name"));
const surname = useFieldValue(form, form.path.of("surname"));
const fullName = [name, surname].filter(Boolean).join(" ");
// 6️⃣ Reactive values watcher (used by preview UI)
const values = form.watchValues();
// 7️⃣ Validation subscription (isolated to this field)
const nameIssues = useFieldIssues(form, form.path.of("name"));
// 8️⃣ Submission handler
const handleSubmit = form.controller.createSubmitHandler(
async (data) => {
await apiClient.registerUser(data);
form.controller.reset(data); // persist submitted data
},
async (issues) => {
console.warn("Validation failed:", issues);
},
);
return (
<div className="grid grid-cols-2 gap-10">
{/* ---------------- Form ---------------- */}
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<FieldRenderer
form={form}
path={form.path.of("name")}
defaultValue=""
render={({ fieldProps, field }) => (
<div className="flex flex-col gap-1">
<label>First Name</label>
<input {...fieldProps} placeholder="John" />
{field.issues[0] && (
<span className="error">{field.issues[0].message}</span>
)}
</div>
)}
/>
<FieldRenderer
form={form}
path={form.path.of("surname")}
defaultValue=""
render={({ fieldProps, field }) => (
<div className="flex flex-col gap-1">
<label>Last Name</label>
<input {...fieldProps} placeholder="Doe" />
{field.issues[0] && (
<span className="error">{field.issues[0].message}</span>
)}
</div>
)}
/>
<FieldRenderer
form={form}
path={form.path.of("address.city")}
defaultValue=""
render={({ fieldProps, field }) => (
<div className="flex flex-col gap-1">
<label>City</label>
<input {...fieldProps} placeholder="Istanbul" />
{field.issues[0] && (
<span className="error">{field.issues[0].message}</span>
)}
</div>
)}
/>
<button type="button" onClick={() => cityField?.setValue("Istanbul")}>
Autofill City
</button>
<button type="submit" disabled={form.controller.isSubmitting}>
{form.controller.isSubmitting ? "Submitting..." : "Create Account"}
</button>
</form>
{/* ---------------- Live Preview ---------------- */}
<aside className="flex flex-col gap-4 p-4 border rounded-xl">
<h3>Preview</h3>
<div>
<strong>Full Name:</strong>
<div>{fullName || "—"}</div>
</div>
<div>
<strong>City:</strong>
<div>{values.address?.city || "—"}</div>
</div>
{nameIssues.length > 0 && (
<div className="text-yellow-600 text-sm">
Your name still needs attention.
</div>
)}
<hr />
<p className="text-sm opacity-70">
This preview updates automatically because it subscribes using
<code>useFieldValue()</code>, not because the whole form re-rendered.
</p>
</aside>
</div>
);
}
Takeaways
useForm()creates a centralized, reactive form state and wires up validation and submission behavior.form.watchValues()lets a component subscribe to all form data changes — re-rendering only where it is called.form.watchIssues()lets a component subscribe to all validation issue changes — ideal for global error summaries or banners.form.watchEvent()lets a component subscribe to a specific internal form lifecycle event — enabling fine-grained reactivity and custom behavior built on top of the controller's event system.
useFormField()gives you full programmatic control over a specific field — including setting, modifying, focusing, and inspecting its state.useFieldValue()lets you subscribe to a single field's value with fine-grained reactivity and minimal re-renders.FieldRenderer(or your custom wrapper) integrates any UI component with automatic field binding.FormController(form.controller) handles submission, validation lifecycle, resetting, and all form-level operations.- Supports deeply nested objects, dynamic arrays, and even custom class instances out-of-the-box.
Together, these primitives let you build complex, high-performance forms with explicit reactivity and minimal boilerplate — while keeping behavior predictable and scalable.