← ProgrammingBack to top

React Hook Form: React form state management

React Hook Form (RHF) is a form management library commonly used in the React ecosystem.

Mainly responsible for:

  • Register and manage form fields.
  • Collect user input.
  • Field validation and error management.
  • Form submission.
  • Manage Dirty, Touched, Submitting and other states.
  • Minimize unnecessary React re-rendering.

React Hook Form mainly manages the client form status, while TanStack Query mainly manages the server status.

Typical workflow:

Django API
    ↓
TanStack Query
    ↓
Server Data
    ↓
React Hook Form
    │
    ├── Field Registration
    ├── Form Values
    ├── Validation
    ├── Error Management
    └── Form State
    ↓
handleSubmit()
    ↓
TanStack Query Mutation
    ↓
Axios
    ↓
Django API

1. Basic usage

Installation:

pnpm add react-hook-form

Basic example:

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

type FormData = {
  username: string;
  email: string;
};

function ProfileForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<FormData>({
    defaultValues: {
      username: "",
      email: "",
    },
  });

  const onSubmit = (data: FormData) => {
    console.log(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input
        {...register("username", {
          required: "Username is required",
        })}
      />

      {errors.username && <p>{errors.username.message}</p>}

      <input
        {...register("email", {
          required: "Email is required",
        })}
      />

      <button type="submit" disabled={isSubmitting}>
        Submit
      </button>
    </form>
  );
}

Compared to the traditional React form, RHF does not require a separate useState to be created for each input field.

2. useForm: form controller

useForm() is Hook, the core of RHF.

const form = useForm<FormData>({
  defaultValues: {
    username: "",
    email: "",
  },
  mode: "onSubmit",
});

It creates and returns a set of form management methods:

const {
  register,
  control,
  handleSubmit,
  watch,
  getValues,
  setValue,
  reset,
  trigger,
  setError,
  clearErrors,
  formState,
} = useForm<FormData>();

useForm Common configuration

parametersfunction
defaultValuesInitialize form values
valuesReactively update form values based on external data
modeTrigger mode for first verification
reValidateModeRevalidation mode for error fields after submission
resolverAccess external verification library
criteriaModeControl how many validation errors are collected per field
shouldFocusErrorWhether to focus on the error field when validation fails
shouldUnregisterWhether to cancel the registration when the field is unloaded
disabledDisable entire form

Validation Mode

ModeVerification timing
onSubmitWhen submitting, the default
onBlurWhen a field loses focus
onChangeWhen input changes
onTouchedAfter the first Blur, subsequent Change
allWhen Blur and Change

For example:

useForm<FormData>({
  mode: "onBlur",
  reValidateMode: "onChange",
});

It means that the first verification is mainly triggered at Blur, and if there are error fields after submission, it will be re-verified at Change.

onChange mode may perform frequent validations, so performance needs to be taken into account for complex forms.

3. register: Registration form field

register() registers native input elements to RHF.

<input {...register("username")} />

Roughly equivalent to:

<input
  name="username"
  onChange={...}
  onBlur={...}
  ref={...}
/>

register() mainly returns:

  • name: field name.
  • onChange: Field change processing function.
  • onBlur: Lost focus processing function.
  • ref: Input element reference.

Differences from traditional controlled components

Traditional React:

const [username, setUsername] = useState("");

<input
  value={username}
  onChange={(event) => setUsername(event.target.value)}
/>;

RHF:

<input {...register("username")} />

RHF usually adopts an uncontrolled management method for native input, and does not need to update the field value through the component's own setState every time it is input.

This helps reduce unnecessary component re-rendering.

Nested objects

register("address.city");
register("address.country");

Corresponds to:

{
  address: {
    city: "Tokyo",
    country: "Japan"
  }
}

Array field

register("users.0.name");

Corresponds to:

{
  users: [{ name: "Alice" }];
}

Convert input type

The value of HTML Input is usually a string.

You can use:

<input
  type="number"
  {...register("age", {
    valueAsNumber: true,
  })}
/>

Or:

<input
  {...register("price", {
    setValueAs: (value) => Number(value),
  })}
/>

Avoid manually converting all numeric fields after the form is submitted.

4. handleSubmit: Form submission

handleSubmit() is responsible for verification before submission and calling the corresponding callback.

Browser Submit
      ↓
handleSubmit
      ↓
Validation
      │
      ├── Success → onValid(data)
      │
      └── Failure → onInvalid(errors)

Basic usage

const onSubmit = (data: FormData) => {
  console.log(data);
};

<form onSubmit={handleSubmit(onSubmit)}>

handleSubmit(onSubmit) Returns a function that can be used as a form submission handler.

Success and failure callbacks

import type {
  SubmitHandler,
  SubmitErrorHandler,
} from "react-hook-form";

const onValid: SubmitHandler<FormData> = (data) => {
  console.log(data);
};

const onInvalid: SubmitErrorHandler<FormData> = (errors) => {
  console.log(errors);
};

<form onSubmit={handleSubmit(onValid, onInvalid)}>

Asynchronous submission

const onSubmit = async (data: FormData) => {
  try {
    await updateProfile(data);
  } catch (error) {
    console.error(error);
  }
};

For asynchronous submission, handleSubmit will wait for Promise returned by the callback.

RHF will not automatically swallow exceptions thrown by the submission handler, so it should be handled by itself.

5. Validation: Field validation

The simplest verification can be written directly in register().

<input
  {...register("username", {
    required: "Username is required",
    minLength: {
      value: 3,
      message: "At least 3 characters",
    },
    maxLength: {
      value: 20,
      message: "At most 20 characters",
    },
  })}
/>

Common validation rules

rulesfunction
requiredRequired
minLengthminimum length
maxLengthmaximum length
minminimum value
maxmaximum value
patternregular expression
validateCustom validation function

For example:

register("age", {
  valueAsNumber: true,
  validate: (value) => value >= 18 || "Must be at least 18",
});

Custom asynchronous validation

register("username", {
  validate: async (value) => {
    const available = await checkUsername(value);

    return available || "Username already exists";
  },
});

This type of verification may require anti-shaking or adjusting the verification triggering time to avoid calling the server for every input.

At the same time, front-end verification cannot replace Django Serializer’s server-side verification.

6. Resolver: External verification library

Resolver is an adapter between RHF and an external Schema Validation Library.

Common validation libraries include:

  • Zod
  • Yup
  • Valibot
  • Joi

Install Zod:

pnpm add zod @hookform/resolvers

Example:

import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";

const schema = z.object({
  username: z.string().min(3).max(20),
  email: z.email(),
});

type FormData = z.infer<typeof schema>;

const form = useForm<FormData>({
  resolver: zodResolver(schema),
});

resolver is responsible for converting external validation results into field errors that RHF can understand.

Do I have to use Zod?

Not required.

Simple forms can be used directly:

register("username", {
  required: true,
  minLength: 3,
});

If you need cross-field validation, complex object validation, or reuse Schema, consider Zod.

If the project has already generated the TypeScript type through OpenAPI + Orval, you can choose whether to add client runtime verification according to actual needs, without having to mechanically repeat the definition of all API Schema.

7. formState: form status

formState is a very important set of states in RHF.

const {
  formState: {
    errors,
    isDirty,
    dirtyFields,
    touchedFields,
    isValid,
    isValidating,
    isSubmitting,
    isSubmitted,
    isSubmitSuccessful,
    submitCount,
  },
} = useForm<FormData>();
Statusmeaning
errorsField validation error
isDirtyWhether the current overall form value is different from the default value
dirtyFieldsDirty status of each field
touchedFieldsFields touched by the user
isValidIs the form verification result valid?
isValidatingIs verification in progress?
isSubmittingWhether the commit callback is being executed
isSubmittedHas it been submitted?
isSubmitSuccessfulWhether the most recent submission was completed successfully
submitCountNumber of submissions

isDirty and dirtyFields

Assumptions:

defaultValues: {
  username: "Alice",
  email: "alice@example.com",
}

The user changes the username to Bob:

isDirty = true

dirtyFields = {
  username: true
}

If the user changes the username back to Alice:

isDirty = false

Because the current value of the entire form is again consistent with defaultValues.

But dirtyFields has field-level state semantics and should not be considered to be always fully synchronized with isDirty.

It is recommended to provide complete and stable defaultValues for all fields, otherwise the Dirty judgment may not meet expectations.

touchedFields

Blur after the field gets focus:

Focus → Blur
      ↓
touchedFields.username = true

Field values are not required to be modified.

isSubmitting

<button type="submit" disabled={isSubmitting}>
  {isSubmitting ? "Submitting..." : "Submit"}
</button>

Note: isSubmitting tracks the submission process of handleSubmit.

If onSubmit just calls:

mutation.mutate(data);

Then returns immediately, isSubmitting usually does not wait continuously for Mutation to complete.

Should use:

const onSubmit = async (data: FormData) => {
  await mutation.mutateAsync(data);
};

Or directly use TanStack Query’s mutation.isPending management server to submit the status.

8. watch: Subscription form value

watch() is used to obtain form values, and UI is updated as the fields change.

For example:

const country = watch("country");

return (
  <>
    <select {...register("country")}>
      <option value="JP">Japan</option>
      <option value="US">United States</option>
    </select>

    {country === "JP" && <input {...register("prefecture")} />}
  </>
);

When country changes, the component can display different fields based on the new value.

Common calling methods

watch(); // Listen to the entire form
watch("username"); // Monitor single field
watch(["city", "country"]); // Monitor multiple fields

watch() may cause the component using it to re-render when the associated value changes.

For complex forms, it is recommended to avoid indiscriminately monitoring all fields at the top level.

9. useWatch: Partial subscription

useWatch() is similar to watch(), but limits the effect of re-rendering to the component using it.

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

function CountryDetails({ control }: { control: Control<FormData> }) {
  const country = useWatch({
    control,
    name: "country",
  });

  return <p>Selected country: {country}</p>;
}

watch and useWatch

FeatureswatchuseWatch
Main functionsGet and subscribe to form valuesGet and subscribe to form values
UsageThe method returned by useFormIndependent Hook
Update scopeMay affect form root componentEasier to isolate in subcomponents
Applicable scenariossimple formComplex forms, partial subscriptions

For example, in a form containing dozens of fields, if only a certain subcomponent needs to listen to country, it is usually more recommended to use useWatch() within the subcomponent.

10. getValues: Read the current value

getValues() can directly read the current field value, but does not establish a subscription.

const username = getValues("username");

Commonly used in event handling functions:

const handleSomething = () => {
  const username = getValues("username");

  console.log(username);
};

watch and getValues

methodWhether to subscribePurpose
watchYesUpdate UI when value changes
useWatchYesLocal component update
getValuesNoTemporarily read the current value

If you only read the value once when the button is clicked, usually use getValues().

11. setValue: Actively modify fields

setValue() is used to modify form values through code.

setValue("username", "Bob");

Status updates can also be actively triggered:

setValue("username", "Bob", {
  shouldDirty: true,
  shouldValidate: true,
  shouldTouch: true,
});
parametersfunction
shouldDirtyUpdate Dirty status
shouldValidatePerform verification
shouldTouchUpdate Touched status

Example

After the user selects a city, the country is automatically filled in:

setValue("country", "Japan", {
  shouldDirty: true,
});

Note: setValue() is not the same as setState() for React.

RHF will decide which components need to be updated based on subscriptions, errors and state changes, usually without causing the entire form to be re-rendered every time.

12. reset: Reset form

reset() is used to reset the value and related status of the form.

reset();

Normally restores to current defaultValues.

Also possible:

reset({
  username: "Alice",
  email: "alice@example.com",
});

In the default configuration, the new value provided becomes the new default value baseline.

Therefore:

Current Values = New Values
Default Values = New Values
isDirty = false

Get user information asynchronously

const { data: user } = useGetUser();

const form = useForm<FormData>({
  defaultValues: {
    username: "",
    email: "",
  },
});

useEffect(() => {
  if (user) {
    form.reset({
      username: user.username,
      email: user.email,
    });
  }
}, [user, form.reset]);

Protect data being edited by users

If the server re-obtains the data but the user has modified some fields, consider:

reset(newData, {
  keepDirtyValues: true,
});

This way the Dirty field retains the user's current input and the other fields are updated based on the new data.

This is especially important for the background Refetch combined with TanStack Query.

Otherwise, if reset(data) is executed unconditionally every time the Query data is updated, the user's unsaved input may be overwritten.

13. values: Responsive external data

The newer version RHF supports synchronizing external data into the form using values.

For example:

const form = useForm<FormData>({
  defaultValues: {
    username: "",
    email: "",
  },
  values: user
    ? {
        username: user.username,
        email: user.email,
      }
    : undefined,
});

Unlike defaultValues, values responds to external data changes.

Note: external data updates may also affect the value currently edited by the user.

Therefore, when editing existing data on the server, you can choose according to your needs:

  • Initialize only once: use reset() or the appropriate values scheme.
  • Continuous synchronization of server data: use values.
  • Preserve user-modified fields: combined with keepDirtyValues.

14. Controller: Controlled component adapter

RHF can directly use register() for native input.

But some third-party UI components use controlled mode, for example:

  • Radix UI Select。
  • React Select。
  • Date picker.
  • Custom Switch.
  • Custom combination input component.

Controller is usually used at this time.

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

<Controller
  name="city"
  control={control}
  render={({ field, fieldState }) => (
    <>
      <Select
        value={field.value}
        onValueChange={field.onChange}
        onBlur={field.onBlur}
      />

      {fieldState.error && <p>{fieldState.error.message}</p>}
    </>
  )}
/>;

The role of Controller

Controller relates the value changes of the controlled component to RHF.

Usually provided:

field.name;
field.value;
field.onChange;
field.onBlur;
field.ref;

Among them:

  • value: Current value.
  • onChange: Write new value back to RHF.
  • onBlur: Notifies the RHF field that it has lost focus.
  • ref: associates underlying input elements and supports functions such as focusing.

For non-native components, whether ref can be passed in directly depends on whether the component exposes the corresponding input element reference.

15. useController

useController() provides similar capabilities to Controller, but in the form of Hook.

const { field, fieldState, formState } = useController({
  name: "username",
  control,
});

Use:

<Input
  value={field.value}
  onChange={field.onChange}
  onBlur={field.onBlur}
  name={field.name}
  ref={field.ref}
/>

Suitable for encapsulating common form components:

FormInput
FormSelect
FormSwitch
FormDatePicker

Controller and useController

ContrastControlleruseController
formReact ComponentReact Hook
Main purposeDirectly adapt to third-party componentsEncapsulate reusable form components
Provide fieldYesYes
Provide fieldStateYesYes

The two use similar control mechanisms at the bottom. Usually, you can choose one of them without having to register the same field repeatedly at the same time.

16. useFieldArray: Dynamic field array

useFieldArray() is used to manage an array of fields that can be dynamically added, deleted, or sorted.

For example, travel planning:

{
  destinations: [{ city: "Tokyo" }, { city: "Kyoto" }];
}

Example

type FormData = {
  destinations: {
    city: string;
  }[];
};

const { register, control } = useForm<FormData>({
  defaultValues: {
    destinations: [{ city: "" }],
  },
});

const { fields, append, remove, update, move } = useFieldArray({
  control,
  name: "destinations",
});

Rendering:

{
  fields.map((field, index) => (
    <input key={field.id} {...register(`destinations.${index}.city`)} />
  ));
}

New:

append({ city: "" });

Delete:

remove(1);

Move:

move(0, 2);

Common methods

methodfunction
appendAdd at the end
prependAdd at the beginning
insertInsert at specified position
removeDelete
swapSwap two elements
moveMove elements
updateReplace specified item
replacereplace entire array

Things to note

Must use:

key={field.id}

Instead of simply using:

key = { index };

Because the index may change after the array is inserted or deleted.

Additionally, update() may cause the corresponding fields to be remounted. If you only need to modify the value of a certain field, consider using setValue().

17. FormProvider and useFormContext

Complex forms are often split into subcomponents:

ProfileForm
├── BasicInfoSection
│   ├── UsernameField
│   └── EmailField
├── AddressSection
│   ├── CountryField
│   └── CityField
└── SubmitButton

If passed layer by layer:

register;
control;
errors;
setValue;

A large number of Props will be generated.

FormProvider can share RHF's form controller through React Context.

parent component

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

function ProfileForm() {
  const methods = useForm<FormData>();

  return (
    <FormProvider {...methods}>
      <form onSubmit={methods.handleSubmit(onSubmit)}>
        <BasicInfoSection />
        <AddressSection />
        <SubmitButton />
      </form>
    </FormProvider>
  );
}

subcomponent

function UsernameField() {
  const {
    register,
    formState: { errors },
  } = useFormContext<FormData>();

  return (
    <>
      <input {...register("username")} />

      {errors.username && <p>{errors.username.message}</p>}
    </>
  );
}

core role

useForm()
    ↓
FormProvider
    ↓
React Context
    ↓
useFormContext()
    ↓
Child Components

There is no need to pass register, control and other methods layer by layer.

Note: You should generally not nest multiple FormProvider unnecessarily in the same form.

18. useFormState: Partial form status subscription

useFormState() allows the component to subscribe to only the required form state.

For example:

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

function SubmitButton() {
  const { control } = useFormContext<FormData>();

  const { isDirty, isSubmitting } = useFormState({
    control,
  });

  return (
    <button type="submit" disabled={!isDirty || isSubmitting}>
      Save
    </button>
  );
}

In this way, SubmitButton can independently subscribe to the relevant status.

Value of useFormState

For large forms, avoid re-rendering the entire form root component with every field update.

Especially suitable for:

  • Independent error message component.
  • SubmitButton。
  • Field grouping.
  • Complex nested forms.

The form status of RHF uses a subscription-based optimization mechanism. It is recommended to only read the status that the component really needs.

19. trigger: Actively perform verification

trigger() Field validation can be performed manually.

Validate the entire form

const valid = await trigger();

if (valid) {
  console.log("All fields are valid");
}

Validate a single field

await trigger("email");

Validate multiple fields

await trigger(["username", "email"]);

Common uses

  • Multi-step forms.
  • Verify before proceeding to the next step.
  • After field A changes, re-validate field B.
  • Proactively check input before specific button operations.

For example:

const handleNextStep = async () => {
  const valid = await trigger(["username", "email"]);

  if (valid) {
    setStep(2);
  }
};

trigger() returns a Boolean value indicating whether the verification passed.

20. setError: Manual setting error

setError() is used to write errors to RHF.

setError("username", {
  type: "manual",
  message: "Username already exists",
});

You can then pass:

errors.username?.message;

Read the error message.

Handle server-side validation errors

For example Django DRF returns:

{
  "username": ["This username already exists."]
}

Server-side errors can be mapped to form fields:

setError("username", {
  type: "server",
  message: "This username already exists.",
});

Non-field level errors

For example, the server returns a common error:

setError("root.server", {
  type: "server",
  message: "Failed to save profile",
});

Shown in UI:

{
  errors.root?.server && <p>{errors.root.server.message}</p>;
}

setError and isValid

setError() can add error status manually, but the final result of isValid is mainly calculated by the form validation rules.

Therefore, setError() should not be understood as a switch that permanently controls isValid.

Errors manually set on registered fields may also be cleared when the field is revalidated.

21. clearErrors: Clear error

clearErrors() is used to manually clear form errors.

Clear individual fields

clearErrors("username");

Clear multiple fields

clearErrors(["username", "email"]);

Clear all errors

clearErrors();

Clear server errors

clearErrors("root.server");

For example, when the user re-edits a field, previous server errors can be cleared:

<input
  {...register("username", {
    onChange: () => {
      clearErrors("root.server");
    },
  })}
/>

The difference between clearErrors and trigger

methodfunction
clearErrors()Clear currently stored errors
trigger()Rerun verification
setError()Manually add errors

Important: clearErrors() does not modify the validation rules, nor does it automatically trigger validation or cause isValid to become true. If the value is still illegal, the error may reappear the next time it is verified.

22. React Hook Form + TanStack Query

For editing forms that already have data on the server, the common process is:

TanStack Query
      ↓
GET User Profile
      ↓
React Hook Form
      ↓
User Edits Fields
      ↓
handleSubmit
      ↓
useMutation
      ↓
PATCH User Profile
      ↓
Update Query Cache
      ↓
reset(savedData)

Example

type ProfileFormData = {
  username: string;
  email: string;
};

function ProfileForm({ initialData }: { initialData: ProfileFormData }) {
  const queryClient = useQueryClient();

  const {
    register,
    handleSubmit,
    reset,
    setError,
    formState: { errors, isDirty },
  } = useForm<ProfileFormData>({
    defaultValues: initialData,
  });

  const mutation = useUserProfilePartialUpdate();

  const onSubmit = async (data: ProfileFormData) => {
    try {
      const updatedProfile = await mutation.mutateAsync({
        data,
      });

      queryClient.setQueryData(
        getUserProfileRetrieveQueryKey(),
        updatedProfile,
      );

      reset({
        username: updatedProfile.username,
        email: updatedProfile.email,
      });
    } catch (error) {
      setError("root.server", {
        type: "server",
        message: "Unable to save profile",
      });
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("username")} />
      <input {...register("email")} />

      {errors.root?.server && <p>{errors.root.server.message}</p>}

      <button type="submit" disabled={!isDirty || mutation.isPending}>
        {mutation.isPending ? "Saving..." : "Save"}
      </button>
    </form>
  );
}

The above example assumes that Mutation returns the complete latest Profile data.

If the backend only returns a 204 or simple success status, it should use invalidateQueries() to re-fetch the data instead of directly writing the Mutation return value to the Profile cache.

Why reset after saving?

Because after successful saving, the current data has become the new value recognized by the server.

For example:

original username = Alice
        ↓
User modified to Bob
        ↓
isDirty = true
        ↓
Server save Bob
        ↓
reset({ username: "Bob" })
        ↓
isDirty = false

In this way, when the user continues editing, the Dirty status will be judged relative to the data just saved.

23. File upload

For file fields you can use:

<input type="file" {...register("avatar")} />

RHF usually receives FileList.

For example:

type AvatarFormData = {
  avatar: FileList;
};

const onSubmit = async (data: AvatarFormData) => {
  const file = data.avatar?.[0];

  if (!file) return;

  const formData = new FormData();
  formData.append("avatar", file);

  await uploadAvatar(formData);
};

For the upload function generated by Orval, OpenAPI Schema and the actual generated parameter type should prevail to avoid repeatedly constructing FormData.

Additionally, browser file inputs cannot set file values ​​arbitrarily like normal text inputs. Therefore the reset and default value handling of file fields need to be considered separately.

24. Performance optimization

One of the advantages of React Hook Form is the reduction of unnecessary re-renders.

Commonly used strategies:

1. Use native input register first

<input {...register("username")} />

It is not necessary to use Controller for every native Input.

2. Complex components use Controller

<Controller
  name="city"
  control={control}
  render={({ field }) => (
    <CustomSelect value={field.value} onValueChange={field.onChange} />
  )}
/>

3. Avoid the full form watch

It is not recommended to use it indiscriminately at the top level of large forms:

watch();

Can be used instead in the required subcomponent:

useWatch({
  control,
  name: "country",
});

4. Partial subscription formState

Use:

useFormState({
  control,
});

Avoid having all state read and rendered by top-level components.

5. Provide complete defaultValues

useForm<FormData>({
  defaultValues: {
    username: "",
    email: "",
  },
});

This helps to accurately determine the Dirty status and avoids the problem of the controlled component encountering the initial value of undefined.

6. Don’t reset frequently

If the server Refetch calls reset() every time, the data being edited by the user may be overwritten.

You can use keepDirtyValues or create a strategy that is initialized only once.

25. FAQ

Q1: Can React Hook Form replace useState?

Only useState, a part of form field state management, can be replaced.

Whether Modal is open, current Tab, page UI status, etc. are still suitable for using React State.

Q2: Do RHF and TanStack Query have overlapping functions?

There is a small amount of overlap, but the main responsibilities are different.

  • RHF: Temporary form status that has not been submitted by the user.
  • TanStack Query: Server data, cache and request status.

The two are often used in combination.

Q3: Why should I use reset when I already have defaultValues?

Because defaultValues is mainly used for initialization.

Server data is usually obtained asynchronously and needs to be synchronized to the form after the acquisition is completed. reset() is one of the commonly used methods.

Q4: Is Controller more advanced than register?

no.

Both target different component types.

Native Input is usually simpler to use register; complex controlled components are more suitable to use Controller.

Q5: The form validation has passed, why does the server still return 400?

Because front-end verification is only user experience optimization, there is no guarantee that the server will accept the data.

The server may have additional:

  • Database unique constraints.
  • Permission check.
  • Business rules.
  • Concurrent state changes.
  • Field conversion and validation.

Therefore, server-side validation of Django Serializer must be retained.

Summary

The core content of React Hook Form can be divided into four levels:

Classificationfocus
basic formuseForm、register、handleSubmit、validation
form statusformState、watch、getValues、setValue、reset
Complex componentsController、useController、useFieldArray、FormProvider
advanced capabilitiesresolver、trigger、setError、clearErrors、useWatch、useFormState

Core process:

useForm()
    ↓
register / Controller
    ↓
Form Values
    ↓
Validation
    ↓
handleSubmit()
    ↓
TanStack Query Mutation
    ↓
Axios
    ↓
Django API
    ↓
Cache Update / Invalidation
    ↓
reset()

For the React + TypeScript + Orval + TanStack Query project, the most important thing is to understand:

  1. The difference between register and Controller.
  2. The relationship between defaultValues, isDirty and dirtyFields.
  3. The difference between watch, useWatch and getValues.
  4. reset How to initialize and redefine form bases.
  5. How server-side validation errors are displayed via setError.
  6. How to manage asynchronous commits using mutateAsync.
  7. Mutation How to synchronize server cache and form status after success.

Reference documentation