React Hook Form 08 ๐ Form State โ isDirty, isValid, touchedFields, and dirtyFields
The formState object is the form’s metadata layer, and the four properties that drive most of the UI feedback are isDirty, isValid, touchedFields, and dirtyFields. They are not independent states that the library computes separately; they are derived from the same internal sourceโthe comparison between the current values and the defaultValues, and the interaction history of each field. The derivation is the mechanism that keeps the four properties consistent with each other, and the defaultValues is the single source of truth that makes the comparison possible .
The distinction between isDirty and dirtyFields is the distinction between the form-level state and the field-level state. The isDirty is true when any field’s current value differs from its default, and it flips back to false when the field returns to the default. The dirtyFields is an object that marks the specific fields that are dirty, and it is the mechanism that lets the UI highlight the individual modified inputs rather than the whole form . The touchedFields is the interaction record, not the value record: a field is touched when it has received a focus and a blur, regardless of whether the value changed.
Key point: isDirty is the form-level boolean that is true when any field’s value differs from its defaultValues. dirtyFields is the per-field object that marks which fields differ. touchedFields is the per-field object that marks which fields have been blurred. isValid is true when there are no validation errors, and it is evaluated on mount when subscribed with mode: "onBlur". All four are derived from the internal form state and the defaultValues, and the formState proxy re-renders only the components that read the changed properties.
Why the four properties exist
The dirty problem. A form with an unsaved-changes warning needs to know whether the user has modified anything. The naive approach is to compare the current values against the initial values in the component, but the component would need to store the initial values and re-run the comparison on every render. The isDirty and the dirtyFields are the library’s mechanism that performs the comparison internally, and the defaultValues is the reference point that the comparison uses . The form-level boolean and the per-field object are the two granularities of the same comparison.
The touched problem. A validation error that appears before the user has finished typing the first character is a hostile UX. The touchedFields is the mechanism that lets the UI show the error only after the field has been blurred, and the mode: "onTouched" is the validation timing that pairs with it . The touched state is the interaction record, and it is the separate concern from the dirty state.
The valid problem. The isValid is the form-level boolean that is true when there are no errors. It is the mechanism that enables the submit button, and it is the state that the mode option affects. With the default mode: "onSubmit", the isValid can be false while the errors object is empty, because the validation has not run yet . With mode: "onBlur", the isValid is evaluated on mount when the component subscribes to it. The distinction is the mechanism that prevents the submit button from being enabled before the validation has run.
The derivation problem. The four properties are not stored as separate flags that the library sets independently. They are derived from the internal _formState and the defaultValues, and the derivation is the mechanism that keeps them consistent. The isDirty is true when the dirtyFields has at least one entry, and the dirtyFields is populated by the deep comparison between the current values and the defaults . The derivation is the reason the two cannot disagree, and the reason the defaultValues must be complete for the comparison to be accurate.
The subscription problem. The formState is a proxy that tracks which properties the component reads during the render. A component that reads isDirty does not re-render when the touchedFields changes, and a component that reads errors does not re-render when the isDirty changes . The proxy is the mechanism that keeps the four properties from causing the whole form to re-render on every change, and it is the reason the narrow destructuring is the recommended pattern.
a. The isDirty and dirtyFields properties
The isDirty is the form-level boolean. It is true when any field’s current value differs from its default, and it flips back to false when the field returns to the default value . The defaultValues is the reference point, and a missing field in the defaults makes the comparison unreliable.
// The isDirty and dirtyFields
import { useForm } from "react-hook-form";
type ProfileFormValues = {
name: string;
email: string;
};
function ProfileForm() {
const {
register,
formState: { isDirty, dirtyFields },
setValue,
} = useForm<ProfileFormValues>({
// The defaultValues is the reference point for the comparison.
defaultValues: { name: "", email: "" },
});
// The isDirty is true when any field differs from the default.
// The dirtyFields is an object with the modified fields.
console.log("isDirty:", isDirty); // false initially
console.log("dirtyFields:", dirtyFields); // {}
return (
<form>
<input {...register("name")} />
<button type="button" onClick={() => setValue("name", "Ada")}>
Set the name
</button>
</form>
);
}
The setValue("name", "Ada") marks the name field as dirty, and the isDirty becomes true. The setValue("name", "") restores the default value, and the isDirty becomes false again . The comparison is the mechanism that makes the revert work, and the defaultValues is the value the comparison uses.
The dirtyFields is the per-field object. It marks the specific fields that are dirty, and the UI can use it to highlight the modified inputs.
// The dirtyFields highlighting
function NameField() {
const {
register,
formState: { dirtyFields },
} = useForm<ProfileFormValues>({
defaultValues: { name: "", email: "" },
});
return (
<div>
<input
{...register("name")}
// The dirtyFields.name is true when the field is modified.
style={{ borderColor: dirtyFields.name ? "orange" : "gray" }}
/>
</div>
);
}
The dirtyFields.name is the per-field flag, and the style is the mechanism that highlights the modified input. The dirtyFields does not necessarily represent the isDirty form state, because the fields are marked as dirty at the field level rather than the entire form . The two are related but not identical.
The defaultValues must be complete for the comparison to be accurate. A missing field in the defaults makes the library treat the field as always dirty, because the comparison has no reference point. The defaultValues is the mechanism that makes the dirty tracking reliable .
b. The touchedFields and isValid properties
The touchedFields is the interaction record. A field is touched when it has received a focus and a blur, regardless of whether the value changed . The object marks the specific fields that have been interacted with, and the UI can use it to show the validation error only after the user has left the field.
// The touchedFields and the error display
function EmailField() {
const {
register,
formState: { errors, touchedFields },
} = useForm<ProfileFormValues>({
defaultValues: { name: "", email: "" },
mode: "onTouched",
});
return (
<div>
<input {...register("email", { required: "The email is required." })} />
{/* The error is shown only after the field is touched. */}
{touchedFields.email && errors.email && (
<p role="alert">{errors.email.message}</p>
)}
</div>
);
}
The touchedFields.email is the interaction flag, and the mode: "onTouched" is the validation timing that runs the validation on blur and then on change after the first error . The combination is the mechanism that shows the error only after the user has had a chance to interact with the field.
The isValid is the form-level boolean. It is true when there are no errors, and it is the mechanism that enables the submit button.
// The isValid and the submit button
function SubmitButton() {
const {
formState: { isValid },
} = useForm<ProfileFormValues>({
defaultValues: { name: "", email: "" },
mode: "onChange",
});
return (
<button type="submit" disabled={!isValid}>
Save
</button>
);
}
The isValid is evaluated on mount when the component subscribes to it with mode: "onBlur" . With the default mode: "onSubmit", the isValid can be false while the errors are empty, because the validation has not run yet. The mode is the mechanism that determines when the isValid is computed, and the choice is the UX decision.
The isValid is not computed while the form is disabled, and the setError immediately forces the isValid to false . The forced false is not derived from the validation, and it is overwritten the next time the validation runs. The distinction is the mechanism that lets the server error disable the submit button without waiting for the next validation.
Complete Example Session
// ============================================
// PART 1: THE IS DIRTY AND THE DIRTY FIELDS
// ============================================
import { useForm } from "react-hook-form";
type ProfileFormValues = {
name: string;
email: string;
};
function ProfileForm() {
const {
register,
formState: { isDirty, dirtyFields },
setValue,
} = useForm<ProfileFormValues>({
defaultValues: { name: "", email: "" },
});
// The isDirty is false initially.
// The dirtyFields is an empty object.
return (
<form>
<input {...register("name")} />
<button type="button" onClick={() => setValue("name", "Ada")}>
Set the name
</button>
{/* The isDirty becomes true when the name is set. */}
{isDirty && <p>The form has unsaved changes.</p>}
</form>
);
}
// ============================================
// PART 2: THE DIRTY FIELDS HIGHLIGHTING
// ============================================
function NameField() {
const {
register,
formState: { dirtyFields },
} = useForm<ProfileFormValues>({
defaultValues: { name: "", email: "" },
});
return (
<input
{...register("name")}
// The dirtyFields.name is true when the field is modified.
style={{ borderColor: dirtyFields.name ? "orange" : "gray" }}
/>
);
}
// ============================================
// PART 3: THE TOUCHED FIELDS
// ============================================
function EmailField() {
const {
register,
formState: { errors, touchedFields },
} = useForm<ProfileFormValues>({
defaultValues: { name: "", email: "" },
mode: "onTouched",
});
return (
<div>
<input {...register("email", { required: "The email is required." })} />
{/* The error is shown only after the field is touched. */}
{touchedFields.email && errors.email && (
<p role="alert">{errors.email.message}</p>
)}
</div>
);
}
// ============================================
// PART 4: THE IS VALID
// ============================================
function SubmitButton() {
const {
formState: { isValid },
} = useForm<ProfileFormValues>({
defaultValues: { name: "", email: "" },
mode: "onChange",
});
return (
<button type="submit" disabled={!isValid}>
Save
</button>
);
}
// ============================================
// PART 5: THE DEFAULT VALUES REFERENCE
// ============================================
// The defaultValues is the reference point for the dirty comparison.
// A missing field makes the comparison unreliable.
useForm<ProfileFormValues>({
defaultValues: { name: "", email: "" },
});
// ============================================
// PART 6: THE RESET AND THE DIRTY STATE
// ============================================
function ResetForm() {
const {
register,
formState: { isDirty, dirtyFields },
reset,
} = useForm<ProfileFormValues>({
defaultValues: { name: "", email: "" },
});
return (
<form>
<input {...register("name")} />
{/* The reset restores the default values and clears the dirty state. */}
<button type="button" onClick={() => reset()}>
Reset
</button>
</form>
);
}
// ============================================
// PART 7: THE NARROW SUBSCRIPTION
// ============================================
// The component reads only isDirty.
// It re-renders only when isDirty changes.
function DirtyIndicator() {
const {
formState: { isDirty },
} = useForm<ProfileFormValues>();
return <>{isDirty && <p>The form has unsaved changes.</p>}</>;
}
// ============================================
// PART 8: THE FIELD STATE
// ============================================
import { getFieldState } from "react-hook-form";
// The getFieldState returns the per-field state.
const fieldState = getFieldState("name");
// The fieldState.isDirty is true when the field is modified.
// The fieldState.isTouched is true when the field is blurred.
// ============================================
// PART 9: THE CRITERIA MODE
// ============================================
// The criteriaMode does not affect the isDirty or the touchedFields.
// It affects the errors object.
useForm<ProfileFormValues>({
criteriaMode: "all",
});
// ============================================
// PART 10: THE CHECKLIST
// ============================================
// [ ] The defaultValues is complete.
// [ ] The isDirty is used for the form-level indicator.
// [ ] The dirtyFields is used for the per-field highlight.
// [ ] The touchedFields is used for the error display timing.
// [ ] The isValid is used for the submit button.
// [ ] The mode is chosen for the validation timing.
The ten parts covered the isDirty and dirtyFields, the dirtyFields highlighting, the touchedFields, the isValid, the defaultValues reference, the reset, the narrow subscription, the getFieldState, the criteriaMode, and the checklist. The through-line is that the four properties are derived from the same source, and the subscription is the mechanism that keeps the re-renders proportional.
Quick Reference
The form state properties
| Property | Type | Meaning |
|---|---|---|
isDirty | boolean | True when any field differs from the default |
dirtyFields | object | The per-field dirty marks |
touchedFields | object | The per-field blur marks |
isValid | boolean | True when there are no errors |
The mode and the isValid
| Mode | isValid on mount | Validation timing |
|---|---|---|
onSubmit | false | On submit |
onBlur | Evaluated | On blur |
onChange | Evaluated | On change |
onTouched | Evaluated | On blur, then on change |
The reset options
| Option | Preserves |
|---|---|
keepDirty | The isDirty and the dirtyFields |
keepTouched | The touchedFields |
keepErrors | The errors |
keepValues | The values |
Best Practices
โ Do This:
// Provide the complete defaultValues
useForm({ defaultValues: { name: "", email: "" } }); // โ
// Use the touchedFields for the error timing
{touchedFields.email && errors.email && <p>{errors.email.message}</p>} // โ
// Read the formState narrowly
const { formState: { isDirty } } = useForm(); // โ
โ Don’t Do This:
// Omit the defaultValues
useForm(); // the isDirty is unreliable // โ
// Read the formState conditionally
return <button disabled={!formState.isDirty} />; // the proxy does not subscribe // โ
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
The isDirty is always true | The defaultValues is missing | Set the defaultValues |
The isValid is always false | The mode is onSubmit and the validation has not run | Set the mode: "onChange" |
| The error shows before the interaction | The touchedFields is not checked | Guard the error with the touched state |
| The reset does not clear the dirty state | The keepDirty is set | Remove the option |
Real-World Examples
1. The isDirty indicator
{isDirty && <p>Unsaved changes</p>}
2. The dirtyFields highlight
style={{ borderColor: dirtyFields.name ? "orange" : "gray" }}
3. The touchedFields error
{touchedFields.email && errors.email && <p>{errors.email.message}</p>}
4. The isValid submit button
<button disabled={!isValid}>Save</button>
5. The defaultValues
useForm({ defaultValues: { name: "", email: "" } })
6. The reset
reset();
7. The narrow subscription
const { formState: { isDirty } } = useForm();
8. The getFieldState
getFieldState("name").isDirty
9. The mode
useForm({ mode: "onTouched" })
10. The keepDirty
reset(undefined, { keepDirty: true })
Visual
The Derived State
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ THE FOUR PROPERTIES ARE DERIVED โ
โ โ
โ The defaultValues โ
โ โ โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ The comparison โ The current values vs the default โ
โ โโโโโโโโโโโโฌโโโโโโโโโโโ โ
โ โ โ
โ โโโโโโโโดโโโโโโโ โ
โ โผ โผ โ
โ โโโโโโโโโโ โโโโโโโโโโ โ
โ โisDirty โ โdirtyFieldsโ โ
โ โboolean โ โobject โ โ
โ โโโโโโโโโโ โโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The Touched Fields
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ THE TOUCHED FIELDS IS THE INTERACTION RECORD โ
โ โ
โ The user focuses the input โ
โ โ โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ The input is focusedโ โ
โ โโโโโโโโโโโโฌโโโโโโโโโโโ โ
โ โ โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ The user blurs โ The touchedFields.email is true โ
โ โ the input โ The error is shown โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The Mode and the isValid
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ THE MODE AFFECTS THE isValid โ
โ โ
โ mode: "onSubmit" โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ isValid: false โ The validation has not run โ
โ โ errors: {} โ The submit button is disabled โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ
โ mode: "onChange" โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ isValid: evaluated โ The validation runs on change โ
โ โ errors: populated โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Summary
| Item | Value |
|---|---|
isDirty | The form-level boolean, true when any field differs from the default |
dirtyFields | The per-field object of the modified fields |
touchedFields | The per-field object of the blurred fields |
isValid | The form-level boolean, true when there are no errors |
| The reference | The defaultValues is the single source of truth |
| The subscription | The formState proxy re-renders only the read properties |
Key takeaways:
- The
isDirtyand thedirtyFieldsare the two granularities of the same comparison. TheisDirtyis the form-level boolean, and thedirtyFieldsis the per-field object. ThedefaultValuesis the reference point, and a missing field makes the comparison unreliable . - The
touchedFieldsis the interaction record, not the value record. A field is touched when it has received a focus and a blur, regardless of the value change. ThetouchedFieldsis the mechanism that lets the UI show the error only after the user has left the field . - The
isValidis affected by themode. With the defaultmode: "onSubmit", theisValidcan be false while the errors are empty, because the validation has not run. Themode: "onBlur"evaluates theisValidon mount when the component subscribes to it . - The
formStateis a proxy. The component re-renders only when the properties it reads during the render change. The narrow destructuring is the mechanism that keeps the re-renders proportional, and the conditional read does not subscribe . - The
resetclears the dirty and the touched state. ThekeepDirty, thekeepTouched, and thekeepErrorsoptions are the mechanism that preserves the specific parts of the state across the reset .
Remember: The React Hook Form form state is the four derived properties. The isDirty and the dirtyFields are the comparison against the defaultValues, and the touchedFields is the interaction record. The isValid is the error-free state, and the mode is the timing. The formState proxy is the mechanism that keeps the re-renders proportional, and the defaultValues is the single source of truth.
Stop using slow, ad-bloated tool sites! ๐คฎ
๐ Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
โ
Finance (Mortgage, Interest, Inflation)
โ
Tech (Base64, JSON, Dev Suite, IP)
โ
Health (BMI, BMR, TDEE)
โ
Productivity (Timer, Workspace, QR)
โก๏ธ Fast & Private
๐ No data leaves your device
๐ 100% Free
๐ Use it now: https://tools.kandz.me
๐ Bookmark itโyouโll need it later!