Skip to content

Migrate V7 to V8 (BETA)

React Hook Form V8 migration guide.

Installation

npm install react-hook-form@beta

React Compiler

V8 supports the React Compiler. useForm returns the same object on every render, so the compiler can't tell when something read through that object changes. To work around this, the read methods register, watch, getValues, getErrors and getFieldState get a new reference whenever form state changes (see Method References). Two patterns keep compiled components up to date:

  • In nested components, subscribe with useFormState instead of reading formState from useFormContext(). The compiler memoizes <FormProvider {...methods}> in the parent, so the context keeps the formState from the first render.
  • Destructure methods such as getFieldState from useForm() instead of calling them on the returned object. The compiler memoizes methods.getFieldState("name") on methods, which never changes, but a destructured getFieldState changes with form state, so its result is recomputed.
function NestedInput() {
const { register, control } = useFormContext()
const { errors } = useFormState({ control }) // ✅ updates under the compiler
return (
<>
<input {...register("name", { required: "Name is required" })} />
<p>{errors.name?.message}</p>
</>
)
}

For updates from a local useFormState subscription, pass its result to getFieldState so the compiler sees that dependency:

const { control, getFieldState } = useForm()
const formState = useFormState({ control })
const { isDirty } = getFieldState("name", formState)

Use useWatch to render values. Under the compiler, a destructured getValues from useFormContext() can stay stale during render. It still reads current values in event handlers.

Flat Field Array

V8's types accept flat field arrays, but primitive rows are not safe to use with useFieldArray yet.

// Before (V7) — each row had to be an object
useFieldArray({ name: "items" }) // items: { value: string }[]
// After (V8) — primitive values type-check
useFieldArray({ name: "items" }) // items: string[]

Note: as of the current beta, this is a types-only improvement — fields still spreads each entry with { ...field, key } internally, which does not behave as expected for primitive array items. useFieldArray also drops '', 0, and false entries from fields, and append then rewrites the stored values without them. Keep each row as an object.

Breaking Changes

The following changes require updates to your existing code when upgrading from V7.

Method References

register, watch, getValues, getErrors and getFieldState no longer keep a stable reference. They get a new reference whenever form state changes, so the React Compiler sees fresh values. The object returned by useForm and the other methods (setValue, handleSubmit, reset, …) stay stable.

Any useEffect, useCallback or useMemo that lists one of these methods as a dependency now runs again on every form state update. For example, useEffect(() => register("x", rules), [register]) registers the field again after unregister("x").

// Before (V7)
useEffect(() => {
register("x", { required: true })
}, [register])
// After (V8) — register during render
register("x", { required: true })

To react to value changes in an effect, use subscribe instead of watch (see watch Callback API).

unknown Instead of any

Public types now use unknown instead of any. Code that relied on any to skip type checking needs explicit types:

  • Callbacks without a parameter type now receive unknown. This applies to setValueAs, onChange and onBlur in register options. Annotate the parameter:

    // Before (V7) — v was any
    register("name", { setValueAs: (v) => v.trim() })
    // After (V8) — annotate the parameter
    register("name", { setValueAs: (v: string) => v.trim() })
  • Controller's field.onChange arguments, ChangeHandler event target and type, RefCallBack instances and CustomElement.value are typed as unknown.

  • TContext defaults to unknown. In a resolver that uses the default context, narrow context before reading from it, or pass a context type: Resolver<FormValues, MyContext>.

  • On untyped forms, nested dirtyFields and touchedFields are unknown. Pass a form type with useForm<FormValues>(), or narrow the value before you access nested fields.

Input Ref

register's returned ref and Controller / useController's field.ref are still callback refs. In V8, RHF stores the supplied element for Controller fields instead of a proxy. This changes FieldError.ref and the ref used for error focus, setFocus, and native validation. To use the node yourself, call field.ref(element) from your own callback ref.

See #12773 for details.

useFieldArray

Two changes affect useFieldArray:

id renamed to key

The internal render identifier has been renamed from id to key. Update any destructured field references:

const { fields, append } = useFieldArray({ name: "items" })
// fields[0].key is the unique render identifier (was fields[0].id)

Note: you can still append id or key as data — neither affects the render key:

append({
key: "custom-key", // stored as field data, does not override the render key
id: "custom-id", // stored as field data, does not override the render key
})
fields[0].key // unique id used for re-render (auto-generated)

keyName prop removed

The keyName option has been removed from useFieldArray. The render key is always key.

Watch Component

The <Watch /> component prop names has been renamed to name for consistency with the rest of the API.

// Before (V7)
<Watch names={["firstName", "lastName"]} />
// After (V8)
<Watch name={["firstName", "lastName"]} />

watch Callback API

The watch(callback) overload has been removed, along with its WatchObserver type. watch now only reads values. Use subscribe to run a callback when values change:

// Before (V7)
useEffect(() => {
const { unsubscribe } = watch((value, { name, type }) => console.log(value))
return () => unsubscribe()
}, [watch])
// After (V8) — use subscribe
useEffect(() => {
return subscribe({
formState: { values: true },
callback: ({ values }) => console.log(values),
})
}, [subscribe])

valueAsDate

With valueAsDate: true, an empty input ("") now returns null instead of Invalid Date. This matches the native HTMLInputElement.valueAsDate behavior.

register("birthday", { valueAsDate: true })
// Empty input
// Before (V7): Invalid Date
// After (V8): null

If you checked for an empty date with isNaN(value.getTime()), check for null instead.

setValue

As of the current beta, setValue still directly updates useFieldArray fields, unchanged from V7 — this section describes a planned change that has not yet shipped. If you need to overwrite an entire field array, the replace method from useFieldArray remains the recommended approach in both V7 and V8:

const { replace } = useFieldArray({ name: "items" })
replace(newItems)

See setValue and useFieldArray docs for details.

Edit