DEV Community

kensaadi
kensaadi

Posted on

useFieldArray is not updating across steps — and why it can't

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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`} />
));
Enter fullscreen mode Exit fullscreen mode

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)