Lesson 15

Reset, Default Values, and External Data

Most forms don't start empty. Edit pages load existing data, profile screens pull from an API, and users expect a "Reset" button to undo their changes. React Hook Form handles all of these scenarios through defaultValues, reset, and setValue. Understanding how they work together is the key to building any form that loads, edits, and reverts data.

defaultValues in useForm

The defaultValues option sets the initial value for every field when the form mounts. It also serves as the baseline for isDirty — RHF compares the current field values against defaultValues to determine whether anything has changed.

const { register, handleSubmit, formState: { isDirty } } = useForm({
  defaultValues: {
    name: "Jane",
    email: "jane@example.com",
    bio: "",
  },
});

With this configuration, the name input renders with "Jane" already filled in. If the user changes it to "Janet", isDirty becomes true. If they change it back to "Jane", isDirty becomes false again — RHF is doing a deep comparison against the defaults, not just tracking whether the user typed something.

Always provide defaultValues when you want predictable reset and dirty-tracking behavior. Without them, RHF has no baseline to compare against.

Async defaultValues

When your initial data comes from an API, you can pass an async function to defaultValues. RHF awaits the function, populates the form with the resolved values, and handles the loading state for you:

const {
  register,
  handleSubmit,
  formState: { isLoading },
} = useForm({
  defaultValues: async () => {
    const res = await fetch("/api/user");
    return res.json();
  },
});

While the async function is in flight, formState.isLoading is true. You can use this to show a loading indicator:

if (isLoading) return <p>Loading...</p>;

Once the promise resolves, the form fields populate with the fetched data. Those fetched values become the new baseline for isDirty, so the form starts in a clean state. This eliminates the need for a separate useEffect to fetch data and manually call reset — RHF handles the entire flow.

reset() with No Arguments

Calling reset() with no arguments resets every field back to the original defaultValues. All form state — isDirty, errors, touchedFields, dirtyFields — is cleared:

const { register, handleSubmit, reset } = useForm({
  defaultValues: {
    name: "Jane",
    email: "jane@example.com",
  },
});

// Somewhere in your UI
<button type="button" onClick={() => reset()}>
  Reset
</button>

If the user changed name to "Janet" and email to "janet@new.com", clicking Reset reverts both fields to "Jane" and "jane@example.com". The form looks and behaves exactly as it did on mount.

reset(newData)

You can pass new values to reset to update the form's data and baseline at the same time. This is the pattern you'll use after fetching fresh data from an API:

const handleRefresh = async () => {
  const res = await fetch("/api/user");
  const freshData = await res.json();
  reset(freshData);
};

After this call, the form fields show the new data and isDirty is false — because the baseline has been updated to match the new values. This is different from simply setting field values, which would make the form dirty relative to the old baseline.

setValue for Single Fields

Sometimes you need to update one field without touching the rest. setValue handles this:

const { register, setValue } = useForm({
  defaultValues: {
    name: "",
    email: "",
    country: "",
  },
});

// Set a single field imperatively
setValue("country", "US");

By default, setValue updates the value but does not trigger validation or mark the field as dirty. You can change this with options:

// Set value and trigger validation + dirty tracking
setValue("country", "US", {
  shouldValidate: true,
  shouldDirty: true,
});

Use setValue when you need to respond to an external event — like a geolocation lookup filling in the country, or a dropdown selection populating related fields. For resetting the entire form, reset is always the better choice.

reset Options

The reset function accepts a second argument with options that control what state is preserved during the reset:

reset(newData, {
  keepDirty: true,    // Don't reset isDirty and dirtyFields
  keepErrors: true,   // Don't clear validation errors
  keepTouched: true,  // Don't reset touchedFields
});

These options are useful in specific scenarios. For example, if you're refreshing data from the server but want to preserve which fields the user has already interacted with, use keepTouched: true. If you want to update the baseline values without clearing errors that the user should still see, use keepErrors: true.

You can combine them as needed:

// Refresh data but keep the user's interaction state intact
reset(freshData, {
  keepDirty: true,
  keepTouched: true,
});

In practice, the default behavior (resetting everything) is what you want most of the time. These options exist for edge cases where you need finer control.

Complete Example: Edit Profile Form

Here's an edit profile form that brings everything together — async data loading, editing, resetting to original values, and refreshing with new data from the server:

import { useForm } from "react-hook-form";

type ProfileData = {
  name: string;
  email: string;
  bio: string;
};

function EditProfileForm() {
  const {
    register,
    handleSubmit,
    reset,
    formState: { isDirty, isSubmitting, isLoading, errors },
  } = useForm<ProfileData>({
    defaultValues: async () => {
      const res = await fetch("/api/user");
      return res.json();
    },
  });

  const onSubmit = async (data: ProfileData) => {
    const res = await fetch("/api/user", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(data),
    });
    const updated = await res.json();
    reset(updated);
  };

  const handleReset = () => {
    reset();
  };

  const handleRefresh = async () => {
    const res = await fetch("/api/user");
    const freshData = await res.json();
    reset(freshData);
  };

  if (isLoading) return <p>Loading profile...</p>;

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {isDirty && (
        <p style={{ color: "orange" }}>You have unsaved changes</p>
      )}

      <div>
        <label htmlFor="name">Name</label>
        <input
          id="name"
          {...register("name", { required: "Name is required" })}
        />
        {errors.name && (
          <p style={{ color: "red" }}>{errors.name.message}</p>
        )}
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          type="email"
          {...register("email", {
            required: "Email is required",
            pattern: {
              value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
              message: "Enter a valid email",
            },
          })}
        />
        {errors.email && (
          <p style={{ color: "red" }}>{errors.email.message}</p>
        )}
      </div>

      <div>
        <label htmlFor="bio">Bio</label>
        <textarea
          id="bio"
          rows={4}
          {...register("bio", {
            maxLength: { value: 200, message: "Bio must be under 200 characters" },
          })}
        />
        {errors.bio && (
          <p style={{ color: "red" }}>{errors.bio.message}</p>
        )}
      </div>

      <div style={{ display: "flex", gap: "8px" }}>
        <button type="submit" disabled={!isDirty || isSubmitting}>
          {isSubmitting ? "Saving..." : "Save"}
        </button>
        <button type="button" onClick={handleReset} disabled={!isDirty}>
          Reset
        </button>
        <button type="button" onClick={handleRefresh}>
          Refresh
        </button>
      </div>
    </form>
  );
}

export default EditProfileForm;

The form loads user data through async defaultValues, so there's no useEffect or manual state management for the initial fetch. While the data loads, isLoading shows a loading message.

Once loaded, the user can edit any field. The isDirty flag drives an unsaved changes banner and controls whether the Save and Reset buttons are enabled. Clicking Reset calls reset() with no arguments, reverting everything to the originally loaded values. Clicking Refresh fetches fresh data from the server and calls reset(freshData), updating both the form fields and the baseline.

After a successful save, the onSubmit handler calls reset(updated) with the server's response. This ensures the form's baseline matches what the server now has — so isDirty goes back to false and the form is in a clean state.

These patterns — defaultValues for initial data, reset() for reverting, reset(newData) for refreshing, and setValue for targeted updates — are the building blocks for any CRUD form. The same patterns apply whether you're building a settings page, an admin editor, or a profile form.