form icon indicating copy to clipboard operation
form copied to clipboard

UseStore array or object values causing unintended behaviour

Open harry-whorlow opened this issue 10 months ago • 9 comments

Describe the bug

Calling useStore with a selector that returns an object or array field can cause weird behaviour. Results have varied from the maximum update depth, to field's un-focusing on handle change.

Interestingly form.Subscribe dose not result in these issues, so as a work around form.Subscribe can be used instead in limited cases. form.getFieldValue can also work but on first render if you're waiting for an async data fetch this can cause the default value to be rendered.

Steps to reproduce

  • create a form with a field that is of type array or object
  • create a useStore with a selector pointing to store.values

Expected behavior

Return the field variable in a manner that doesn’t break.

How often does this bug happen?

Every time / on hot reload

Screenshots or Videos

My production instance onChange causing the field to be come unfocused. (unfortunately i cannot share more) https://github.com/user-attachments/assets/c953fa68-13cf-4b6a-af8e-3b51efbead8e

Platform

MacOs Sequoia 15.3 Version 132.0.6834.160 (Official Build) (arm64)

TanStack Form adapter

None

TanStack Form version

0.41.3

TypeScript version

5.6.2

Additional context

I tried recreating this in a code sandbox https://codesandbox.io/p/sandbox/yv7zmh?file=%2Fsrc%2FStoreTest%2FWithStore%2Findex.tsx%3A8%2C24, but can't get the same results, but this happens every time in my work environment and also in others users environments. I'll try again to recreate this behaviour or at least another local instance in a code base I can share.

[edit] so I've recreated a simple vite app with just form at the latest version and can recreate the error, but it is a weird one. The Maximum update depth exceeded error throws only on the hot reload for the dev server, as in if I save a change and the dev server reloads the app the error throws. If I refresh the error goes away

Image

function App() {
  const form = useForm({
    defaultValues: { name: '', array: [{ hi: '' }], obj: {} },
  });

  const data = useStore(form.store, (store) => store.values);

  return (
    <Stack gap={2}>
      <> {JSON.stringify(data)}</>

      <form.Field name="array" mode="array">
        {(field) => {
          return (
            <Stack gap={0.5}>
              {field.state.value.map((_, i) => (
                <button key={i} onClick={() => field.pushValue({ hi: '' })}>
                  Add Value
                </button>
              ))}
            </Stack>
          );
        }}
      </form.Field>

      <form.Field
        name="name"
        listeners={{
          onChange: ({ value }) => {
            console.log(value);
          },
        }}>
        {({ handleChange, state }) => <input value={state.value} onChange={(e) => handleChange(e.target.value)} />}
      </form.Field>
    </Stack>
  );
}

I currently cannot recreate the un-focusing bug I have in my production server but i will continue to investigate, as it's pretty debilitating.

harry-whorlow avatar Feb 11 '25 23:02 harry-whorlow

Further details can be found at: https://discord.com/channels/719702312431386674/1100437019857014895/1339292949980250208

harry-whorlow avatar Feb 12 '25 18:02 harry-whorlow

Here are my observations:

  • if there's only an array field, react crashes with that error, even after a refresh. (forked and extended the tanstack array example to reproduce this issue)
  • if there are additional fields (e.g., text fields alongside an array field), hot reload fails with that error, but a refresh fixes it.

beeirl avatar Feb 12 '25 19:02 beeirl

Oooooooh. I sat down to debug this and I just realized why this is happening...

The TLDR is that by using a non-stable value (IE a new object or array reference on every render) we can't detect when it should stop re-rendering :/

We could introduce an ESLint plugin that prevents users from doing stuff like this, but at very least we should document that useStore should be treated more like useSelector and not return any data transforms.

@harry-whorlow @beeirl do either of you want to add this to our docs where you best see fit? (maybe even in source code so it's easily seen in auto-gen'd reference docs?)

crutchcorn avatar Feb 21 '25 15:02 crutchcorn

@crutchcorn sure thing, I'll draft one now🤟

So basically this mapping here is causing infinite re-renders. Image

harry-whorlow avatar Feb 21 '25 19:02 harry-whorlow

@crutchcorn makes sense. should've figured that out myself. so the solution here is to use multiple useStore hooks if the selection includes non-stable values e.g.

const people = useStore(form.store, (state) => state.values.people)
const rest = useStore(form.store, (state) => ({
   foo: state.values.foo,
   bar: state.values.bar,
})

beeirl avatar Feb 24 '25 10:02 beeirl

Would this also affect code like

  const errors = useStore(field.store, (state) => state.meta.errors)

since it is returning an array.

Taken from docs

karan042 avatar Jul 08 '25 06:07 karan042

Mhm just ran into this but separate useStores did not fix my problem. (at first, given the above thread, I thought the below was the problem).

  const [items, canSubmit, changed] = useStore(itemsForm.store, (state) => [
    state.values.items,
    state.canSubmit,
    !state.isDefaultValue,
  ]);

For my form, to get my defaultValues my code calls a function toForm to get the values, and returns:

return {
   items: stuff.map((s) => ({ ...s, id: 'id' in s ? s.id : uniqueId() }) 
}

where uniqueId is imported from lodash.

This code would crash. Couldn't figure it out. Turns out, I tried making the following change:

id: 'id' in s ? s.id : ''

and suddenly the app did not crash anymore. I don't understand this at all but I'm sure there's a reasonable explanation.

marc-at-brightnight avatar Sep 17 '25 14:09 marc-at-brightnight

@marc-at-brightnight this is because your unique value isn't stable either. The uniqueId is triggering a new value for each run of useStore so as a result it breaks the shallow comparison of the store values

crutchcorn avatar Sep 18 '25 13:09 crutchcorn