If you have a wizard where step two appends to items and step four also appends to items, and the two steps never see each other's data — you have not misconfigured anything. Two useFieldArray instances pointed at the same name cannot observe each other, by design. Each one keeps a private snapshot of that array internally.
react-hook-form documents it plainly:
No two useFieldArray for the same field can be instantiated, as each handles its own snapshot of state internally.
For a single-page form this never surfaces. For a wizard it surfaces on day one.
Why the rule exists
This is not neglect, and it is worth understanding before you work around it.
The reason react-hook-form is fast is that state lives close to the inputs and is not centralised. Inputs are uncontrolled, the form does not re-render on every keystroke, and each useFieldArray owns its slice. Sharing one array across mounting boundaries asks for the opposite architecture — a central store that every instance subscribes to.
That tension is real. It is why the request keeps reopening rather than getting fixed.
The three shapes where it bites
Multi-page forms. Different steps need to update the same field array, and there is no first-party way to share one instance across them.
Conditional fields inside an array item. Uncontrolled inputs do not re-render when a value changes, so showing or hiding a nested field based on a sibling's value simply does not react.
Arrays that mount and unmount. Toggled sections, wizard steps and tab navigation all remount the array — the scenario most likely to lose state in a way that looks like a bug in your own code.
Fix 1 — conditional fields: subscribe narrowly with useWatch
For "show this field only when a sibling has a certain value", there is a clean first-party answer. Do not reach for watch() on the whole form: that re-renders everything and throws away the reason you picked this library.
Instead, extract the dependent field into its own component and subscribe to that one array item with useWatch:
import { useFormContext, useWatch } from 'react-hook-form';
// One component per conditional field. The subscription is narrow,
// so the re-render is narrow too.
function CourierNote({ index }: { index: number }) {
const { control, register } = useFormContext();
// Subscribes to exactly items.<index>.method — nothing else re-renders.
const method = useWatch({
control,
name: `items.${index}.method`,
});
if (method !== 'courier') return null;
return (
<input
{...register(`items.${index}.note`)}
placeholder="Delivery instructions"
/>
);
}
function ItemRow({ index, name }: { index: number; name: string }) {
const { register } = useFormContext();
return (
<div>
<input {...register(`${name}.sku`)} />
<select {...register(`${name}.method`)}>
<option value="pickup">Pickup</option>
<option value="courier">Courier</option>
</select>
<CourierNote index={index} />
</div>
);
}
The whole trick is the component boundary. useWatch re-renders the component that called it, so keeping that component tiny keeps the cost tiny. You get reactivity without giving up the uncontrolled model.
Fix 2 — sharing one array across steps: own the context
For the wizard case there is no first-party answer. The pattern that circulates — and the one that actually works — is to instantiate the field array once and hand it down through your own React context, so every step reads the same instance instead of creating a second one.
import { createContext, useContext, type ReactNode } from 'react';
import {
useFieldArray,
useFormContext,
type UseFieldArrayReturn,
} from 'react-hook-form';
const ItemsContext = createContext<UseFieldArrayReturn | null>(null);
export function ItemsProvider({ children }: { children: ReactNode }) {
const { control } = useFormContext();
// The ONLY useFieldArray('items') in the tree.
const fieldArray = useFieldArray({ control, name: 'items' });
return (
<ItemsContext.Provider value={fieldArray}>{children}</ItemsContext.Provider>
);
}
export function useItems() {
const ctx = useContext(ItemsContext);
if (!ctx) throw new Error('useItems must be used inside <ItemsProvider>');
return ctx;
}
Now every step consumes the same instance:
function StepTwo() {
const { fields, append } = useItems();
return (
<>
{fields.map((f, i) => <ItemRow key={f.id} index={i} name={`items.${i}`} />)}
<button type="button" onClick={() => append({ sku: '', method: 'pickup' })}>
Add item
</button>
</>
);
}
function StepFour() {
// Same array, same snapshot — because it is literally the same instance.
const { fields, remove } = useItems();
return (
<ul>
{fields.map((f, i) => (
<li key={f.id}>
{f.id} <button type="button" onClick={() => remove(i)}>×</button>
</li>
))}
</ul>
);
}
The detail that decides whether this works
The provider must be mounted above the step switch, not inside it.
// ✅ survives step changes
<FormProvider {...methods}>
<ItemsProvider>
{step === 2 && <StepTwo />}
{step === 4 && <StepFour />}
</ItemsProvider>
</FormProvider>
// ❌ remounts on every step change — you are back where you started
<FormProvider {...methods}>
{step === 2 && <ItemsProvider><StepTwo /></ItemsProvider>}
{step === 4 && <ItemsProvider><StepFour /></ItemsProvider>}
</FormProvider>
If the provider unmounts, the field array unmounts with it, and the second mount is a brand-new instance with a fresh snapshot. Which is the original bug, reintroduced by the fix.
It works. It is also a piece of state architecture you now own and maintain — in a library you adopted specifically so you would not have to.
Where we stand
I maintain a form orchestrator built on top of react-hook-form, so it would be a strange article to end with a pitch. Our own useDashFieldArray does not solve this either.
It is a thin adapter: it pre-computes the field path and exposes a live index, then delegates every operation straight through to RHF.
const { fields, append, remove } = useDashFieldArray<Item>('items');
fields.map((field) => (
<TextField key={field.id} name={`${field.name}.sku`} />
));
Better DX — you stop hand-building items.${index}.sku strings — but two instances on the same name behave exactly as RHF's do, because underneath they are RHF's. Fixing this properly means owning array state centrally, which is a different architecture, not a wrapper.
So the honest summary for today:
- Conditional fields inside an array → solved, use useWatch at the component level.
- One array shared across wizard steps → not solved upstream. Own a context, and mount it above the steps.
If someone has a third pattern that holds up in production, I would genuinely like to see it.
Sources
react-hook-form — useFieldArray documentation the quoted rule
react-hook-form — useWatch documentation
This is part of Fault Lines, a set of open fronts in the React form ecosystem, each traced to primary sources and dated.
Top comments (0)