React Hook Form 25 ๐ Migration Guide โ Upgrading from v7 to v8
React Hook Form v8 is currently in beta, not a stable release. The official migration guide is labeled “BETA,” and the stable line remains v7 (7.85 as of August 2026) . This chapter documents the breaking changes and new patterns for teams evaluating the upgrade, but the first and most important decision is whether to migrate at all in production. The v8 changes are real and substantial: method references become unstable, any becomes unknown, useFieldArray changes its identifier and update semantics, and React Compiler support reshapes how form state must be read.
This chapter covers the installation path, the breaking changes that require code edits, the React Compiler patterns that v8 exists to support, and the migration steps in the order they should be applied. It explains why each change was made and what breaks without it, so the migration is a set of understood decisions rather than a sequence of error-driven fixes. The sections that follow walk through method references, type tightening, field array changes, the removed watch callback, and the setValue restriction.
Key point: v8 is a beta that trades stability for React Compiler compatibility and stricter types; migrate only when the React Compiler is on your roadmap, and never install react-hook-form@beta in a production branch without a rollback plan.
Why v8 migration exists
The React Compiler compatibility problem. React Hook Form’s useForm returns the same object on every render, which is why its API feels stable. The React Compiler cannot tell when a value read through that stable object changes, because the object reference never changes. v8 solves this by giving register, watch, getValues, getErrors, and getFieldState a new reference whenever form state changes, so the compiler detects the dependency and recomputes . This is the single change that cascades into most migration work: any useEffect, useCallback, or useMemo that lists one of these methods as a dependency now re-runs on every form state update.
The any debt problem. v7’s public types used any in callbacks and generic defaults, which let untyped code pass without errors. v8 replaces any with unknown, forcing explicit type annotations. A setValueAs: (v) => v.trim() that compiled in v7 fails in v8 because v is unknown and has no trim method; the fix is (v: string) => v.trim() . The change is deliberate: any hid type errors that caused runtime bugs, and unknown makes them compile-time failures.
The useFieldArray API inconsistency problem. v7’s useFieldArray generated a render identifier called id and spread it into each row, which collided with users’ own id properties from database records. v8 renames the generated identifier to key, removes the keyName prop that existed to work around the collision, and prevents setValue from writing to field arrays so that array updates go through the field array’s own replace method . Each change removes a source of confusion, at the cost of a code edit for every existing field array.
The beta stability trade-off. The official guide opens with npm install react-hook-form@beta, not a version number. The beta has known issues, including a report that formState.errors changes do not cause child components to re-render under the React Compiler in beta.2, a bug later fixed in v7.79 and synced to the beta . Migrating a production form to beta means accepting that the API can change between beta releases before v8.0.0 final.
a. Method references and effect dependencies
The methods that read form state now change identity on every state update. The migration is to stop using them as effect dependencies and to register during render instead.
// BEFORE (v7): register in an effect, with register as a dependency
useEffect(() => {
register("x", { required: true });
}, [register]); // ran once in v7; re-registers after unregister in v8
// AFTER (v8): register during render
register("x", { required: true });
For effects that need to react to value changes, replace watch with subscribe. The watch callback API is removed entirely in v8; subscribe is its replacement .
// BEFORE (v7): watch with a callback
useEffect(() => {
const subscription = watch((value) => console.log(value));
return () => subscription.unsubscribe();
}, [watch]);
// AFTER (v8): subscribe with formValues flag
useEffect(() => {
const subscription = subscribe({
formValues: true,
callback: ({ values }) => console.log(values),
});
return () => subscription.unsubscribe();
}, [subscribe]);
b. unknown instead of any
Every callback parameter that was implicitly any now needs an explicit type. The compiler error message names the parameter; annotate it and the error clears.
// BEFORE (v7): v is any, compiles silently
register("name", { setValueAs: (v) => v.trim() });
// AFTER (v8): v is unknown, must be narrowed
register("name", { setValueAs: (v: string) => v.trim() });
The same applies to Controller‘s field.onChange arguments, ChangeHandler event targets, and TContext in resolvers, which now defaults to unknown . In a resolver that uses the default context, narrow before reading, or pass a context type: Resolver<FormValues, MyContext>. For untyped forms, nested dirtyFields and touchedFields are unknown; passing useForm<FormValues>() resolves this.
c. useFieldArray changes
Three edits apply to every field array: field.id becomes field.key, the keyName prop is removed, and setValue on an array is replaced by replace.
// BEFORE (v7): id is the render key, keyName could rename it
const { fields } = useFieldArray({ name: "items", keyName: "customKey" });
fields.map((field) => <input key={field.id} />);
// AFTER (v8): key is the render key, no keyName
const { fields } = useFieldArray({ name: "items" });
fields.map((field) => <input key={field.key} />);
// BEFORE (v7): setValue overwrites the entire array
setValue("items", newItems);
// AFTER (v8): replace from useFieldArray
const { replace } = useFieldArray({ name: "items" });
replace(newItems);
A flat field array is now type-accepted, but the beta guide includes a warning: primitive rows are not safe to use with useFieldArray yet, because fields still spreads each entry internally and drops '', 0, and false. Keep each row as an object in the beta .
Complete Example Session
// ============================================
// PART 1: INSTALL AND VERSION PIN
// ============================================
// npm install react-hook-form@beta
// Verify the installed beta version before migrating:
// npm ls react-hook-form
// Expected: react-hook-form@8.0.0-beta.x
// ============================================
// PART 2: METHOD REFERENCES โ REGISTER IN RENDER
// ============================================
import { useForm } from "react-hook-form";
function LegacyRegister() {
const { register } = useForm();
// BEFORE: effect with register dependency
// useEffect(() => { register("x", { required: true }) }, [register]);
// AFTER: call during render
register("x", { required: true });
return <input {...register("x")} />;
}
// ============================================
// PART 3: SUBSCRIBE REPLACES WATCH CALLBACK
// ============================================
import { useEffect } from "react";
function ValueLogger() {
const { subscribe } = useForm();
useEffect(() => {
const subscription = subscribe({
formValues: true, // subscribe to values, not just formState
callback: ({ values }) => console.log("values", values),
});
return () => subscription.unsubscribe();
}, [subscribe]);
return null;
}
// ============================================
// PART 4: TYPED CALLBACKS FOR UNKNOWN
// ============================================
type FormValues = { name: string; age: number };
function TypedForm() {
const { register } = useForm<FormValues>();
return (
<form>
{/* v7: (v) => v.trim() compiled with any */}
{/* v8: annotate the parameter */}
<input
{...register("name", {
setValueAs: (v: string) => v.trim(),
})}
/>
</form>
);
}
// ============================================
// PART 5: FIELD ARRAY MIGRATION
// ============================================
type Item = { value: string };
function ItemList() {
const { control, register } = useForm<{ items: Item[] }>({
defaultValues: { items: [{ value: "" }] },
});
const { fields, append, replace } = useFieldArray({
control,
name: "items",
});
// BEFORE: fields.map((field) => <input key={field.id} ... />)
// AFTER: key={field.key}
return (
<>
{fields.map((field, index) => (
<input
key={field.key}
{...register(`items.${index}.value`)}
/>
))}
<button type="button" onClick={() => append({ value: "" })}>
Add
</button>
<button type="button" onClick={() => replace([{ value: "reset" }])}>
Replace all
</button>
</>
);
}
// ============================================
// PART 6: REACT COMPILER โ useFormState IN NESTED COMPONENTS
// ============================================
import { useFormContext, useFormState } from "react-hook-form";
function NestedError() {
const { register, control } = useFormContext<FormValues>();
// v8: subscribe with useFormState, not formState from context
const { errors } = useFormState({ control });
return (
<>
<input {...register("name", { required: "Name is required" })} />
<p>{errors.name?.message}</p>
</>
);
}
The parts covered installation and version verification, the register-in-render migration, the subscribe replacement for the removed watch callback, typed callbacks for unknown, the three useFieldArray edits, and the React Compiler pattern for nested components.
Quick Reference
Breaking Changes Summary
| Change | v7 | v8 | Required Edit |
|---|---|---|---|
| Method references | Stable | New ref on state change | Remove from effect deps |
register in effect | useEffect(..., [register]) | Runs on every update | Call during render |
| Callback params | any | unknown | Annotate parameter types |
useFieldArray key | field.id | field.key | Rename in key prop |
keyName prop | Supported | Removed | Delete from options |
watch callback | Supported | Removed | Use subscribe |
setValue on array | Overwrites | No longer updates | Use replace |
React Compiler Patterns
| Pattern | v7 | v8 |
|---|---|---|
| Nested error display | formState from context | useFormState({ control }) |
| Method in effect | useEffect(..., [register]) | Register in render |
| Render values | watch | useWatch |
| Value changes in effect | watch callback | subscribe |
Migration Order
| Step | Action | Risk |
|---|---|---|
| 1 | Pin beta version, run tests | Baseline failures |
| 2 | Fix unknown annotations | Compile errors |
| 3 | Remove method refs from deps | Infinite loops, re-registration |
| 4 | Replace watch callback | Runtime removal |
| 5 | Rename field.id to field.key | Render key warnings |
| 6 | Replace setValue arrays | Silent no-op |
| 7 | Adopt useFormState in nested | Stale errors under compiler |
Best Practices
โ Do This:
// Pin the exact beta version during migration
"react-hook-form": "8.0.0-beta.4" // โ
// Annotate unknown callback parameters
setValueAs: (v: string) => v.trim() // โ
// Register during render
register("x", { required: true }); // โ
// Use subscribe for value-change effects
subscribe({ formValues: true, callback: ({ values }) => {} }) // โ
// Use field.key as the render key
fields.map((field) => <input key={field.key} />) // โ
// Use replace for array overwrites
const { replace } = useFieldArray({ name: "items" }); // โ
// Use useFormState in nested components under the compiler
const { errors } = useFormState({ control }); // โ
โ Don’t Do This:
// Unpinned beta in a production branch
npm install react-hook-form@beta // โ
// Any-typed callback
setValueAs: (v) => v.trim() // โ (v is unknown)
// Method as effect dependency
useEffect(() => register("x"), [register]); // โ (re-runs always)
// Removed watch callback
watch((value) => console.log(value)); // โ
// Old field.id render key
fields.map((field) => <input key={field.id} />) // โ
// setValue on entire array
setValue("items", newItems); // โ (no-op in v8)
// formState from context in nested compiled component
const { formState } = useFormContext(); // โ (stale)
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Infinite re-registration | register in effect dep list | Call register during render |
v.trim is not a function | Parameter is unknown | Annotate (v: string) |
| Field array rows not reordering | field.id no longer exists | Use field.key |
setValue on array silently fails | v8 requires replace | Destructure replace from useFieldArray |
| Errors stale under compiler | formState from context memoized | useFormState({ control }) |
watch callback undefined | API removed | Use subscribe |
| Beta bug in production | v8 is not stable | Stay on v7.85 for production |
Real-World Examples
1. Register during render
register("x", { required: true });
2. Annotate setValueAs parameter
register("name", { setValueAs: (v: string) => v.trim() });
3. Subscribe for value changes
subscribe({ formValues: true, callback: ({ values }) => log(values) });
4. Field array key rename
fields.map((field) => <input key={field.key} />);
5. Replace array contents
const { replace } = useFieldArray({ name: "items" });
replace(newItems);
6. Remove keyName prop
useFieldArray({ name: "items" }); // no keyName
7. useFormState in nested component
const { errors } = useFormState({ control });
8. Destructure getFieldState under compiler
const { getFieldState } = useForm();
const { isDirty } = getFieldState("name", formState);
9. useWatch to render values
const value = useWatch({ control, name: "email" });
10. Version check before migration
npm ls react-hook-form
# react-hook-form@8.0.0-beta.4
Visual
Breaking Change Dependency Chain
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ WHY ONE CHANGE CASCADES โ
โ โ
โ React Compiler support โ
โ โ โ
โ โผ โ
โ Method refs unstable (register, watch, getValuesโฆ) โ
โ โ โ
โ โโโโบ useEffect([register]) re-runs โ
โ โโโโบ useCallback([watch]) re-creates โ
โ โโโโบ useMemo([getValues]) recomputes โ
โ โ
โ any โ unknown โ
โ โ โ
โ โโโโบ setValueAs param must be typed โ
โ โโโโบ Controller onChange typed โ
โ โโโโบ resolver TContext defaults unknown โ
โ โ
โ useFieldArray API consistency โ
โ โ โ
โ โโโโบ id โ key (render identifier) โ
โ โโโโบ keyName removed โ
โ โโโโบ setValue arrays โ replace โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Migration Decision Tree
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ SHOULD YOU MIGRATE? โ
โ โ
โ Is React Compiler enabled or planned? โ
โ โ โ
โ โโโโโโดโโโโโ โ
โ โผ โผ โ
โ YES NO โ
โ โ โ โ
โ โผ โผ โ
โ v8 beta Stay on v7.85 โ
โ required (stable, maintained) โ
โ โ โ โ
โ โผ โผ โ
โ Accept No migration โ
โ beta needed โ
โ instability โ โ
โ โ โ โ
โ โผ โผ โ
โ Run full Revisit when โ
โ test suite v8.0.0 final โ
โ on branch ships โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The useFieldArray Edit Map
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ THREE EDITS PER FIELD ARRAY โ
โ โ
โ BEFORE (v7) AFTER (v8) โ
โ โโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโ โ
โ โ key={field.id} โโโโโโโบโ key={field.key} โ โ
โ โโโโโโโโโโโโโโโโโโโโโโค โโโโโโโโโโโโโโโโโโโโโโค โ
โ โ keyName: "custom" โโโโโโโบโ (removed) โ โ
โ โโโโโโโโโโโโโโโโโโโโโโค โโโโโโโโโโโโโโโโโโโโโโค โ
โ โ setValue("arr", x) โโโโโโโบโ replace(x) โ โ
โ โโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโ โ
โ โ
โ Flat arrays: โ
โ Types accept string[], but rows still spread โ
โ internally โ keep objects in beta. โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Summary
| Item | Value |
|---|---|
| v8 status | Beta, not stable |
| Stable version | v7.85 (August 2026) |
| Install command | npm install react-hook-form@beta |
| Method refs | register, watch, getValues, getErrors, getFieldState unstable |
| Type change | any โ unknown |
| Field array key | field.id โ field.key |
| Array update | setValue โ replace |
| Watch callback | Removed, use subscribe |
| React Compiler | useFormState({ control }) in nested components |
| Flat field array | Types only, not safe in beta |
Key takeaways:
- v8 exists for React Compiler support. The unstable method references are the mechanism that lets the compiler detect form state changes, and they are the source of most migration edits .
unknownis a feature, not an obstacle. Every annotation you add is a type error the compiler can now catch, replacing the silentanythat let bugs through.useFieldArrayhas three breaking edits. Renameidtokey, deletekeyName, and replacesetValuearray writes withreplace.- The
watchcallback is gone.subscribewithformValues: trueis the replacement for effects that react to value changes. - Production stays on v7. The official guide is labeled beta and the beta has known issues; migrate on a branch, run the full test suite, and wait for v8.0.0 final before merging .
- Nested components need
useFormState. Under the compiler,formStateread fromuseFormContext()stays stale; subscribing with thecontrolobject keeps errors live .
Remember: The v7-to-v8 migration is not a version bump; it is a recompile of the assumptions v7 allowed. Method references that never changed now change constantly, any parameters now require types, field arrays use a different identifier, and array writes go through a different method. Each change removes a source of hidden behavior that the React Compiler and stricter TypeScript expose. Do the migration on a branch with the beta pinned, run the existing test suite before and after each category of edit, and keep production on v7 until the beta label is gone.
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!