React Package

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 useFormField and FieldRenderer.
  • How validation works with zod schemas (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;
}
Notice how 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.
By default, 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.

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 data and original event.
  • 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>;
This will:
  • 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 data and the original event.
  • Call the second callback if validation fails, passing in a list of validation issues for you to handle or display.
  • Automatically set the form state to submitting during the async operation, which you can use to disable inputs or show a loading state.
  • Allow you to reset or persist the form data after a successful submission using form.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 onChange or onBlur wiring.

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 value changes.
  • 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 issues of the given field path.
  • Re-renders the component when that field's issues change.
  • 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 data via form.watchValues()
  • Any change in validation issues via form.watchIssues()
  • Any event emitted by the form.controller.events via form.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 data changes.
  • 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 issues change.
  • 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.

Copyright © 2026