T
TanStack4w ago
unwilling-turquoise

useStore causing weird behavior to form.reset()

GitHub
GitHub - mamlzy/tanstack-form-repro
Contribute to mamlzy/tanstack-form-repro development by creating an account on GitHub.
28 Replies
fair-rose
fair-rose4w ago
This likely is because the rerender adds another check of useAppForm's defaultValues against the form reset's defaultValues. Does the error persist if you add a { keepDefaultValues: true } as second argument to the reset?
unwilling-turquoise
unwilling-turquoiseOP4w ago
Adding { keepDefaultValues: true } only works in handleSet. The bug still occurs in handleReset, it doesn’t reset to the latest defaultValues, which contain 2 dummy persons.
unwilling-turquoise
unwilling-turquoiseOP4w ago
fair-rose
fair-rose4w ago
where are your defaultValues coming from?
unwilling-turquoise
unwilling-turquoiseOP4w ago
defaultValues coming from formOpts in shared.ts
// shared.ts
import * as z from "zod";
import { formOptions } from "@tanstack/react-form";

export const createSchema = z.object({
persons: z.array(
z.object({
name: z.string().trim().nonempty(),
age: z.coerce.number<number>().min(0).max(120),
}),
),
});

export type CreateSchema = z.infer<typeof createSchema>;

export const formOpts = formOptions({
defaultValues: {
persons: [] as CreateSchema["persons"],
},
});
// shared.ts
import * as z from "zod";
import { formOptions } from "@tanstack/react-form";

export const createSchema = z.object({
persons: z.array(
z.object({
name: z.string().trim().nonempty(),
age: z.coerce.number<number>().min(0).max(120),
}),
),
});

export type CreateSchema = z.infer<typeof createSchema>;

export const formOpts = formOptions({
defaultValues: {
persons: [] as CreateSchema["persons"],
},
});
fair-rose
fair-rose4w ago
The main source of the problem is our async defaultValues support. You can write async data like this, for example:
const form = useForm({
defaultValues: myAsyncData ?? emptyData,
})
const form = useForm({
defaultValues: myAsyncData ?? emptyData,
})
and it'll update when the new data is ready. The problem is that form.reset might happen right after a call to the backend, so in the small window where async data isn't there yet but the form was reset, it would fall back to emptyData. The passed value to form.reset was originally intended to hide that flickering. You noticed yourself that it becomes problematic though. With the useStore, it triggers a new update, and the form sees:
const form = useForm({
defaultValues: myAsyncData ?? emptyData // both of these don't match the currently stored defaultValues
})
const form = useForm({
defaultValues: myAsyncData ?? emptyData // both of these don't match the currently stored defaultValues
})
So it overrides them, thinking it's async data coming in. keepDefaultValues would force the reset's provided value to be considered the source of truth. But that becomes a problem again when you pass it undefined, as you saw
unwilling-turquoise
unwilling-turquoiseOP4w ago
So this is a bug that needs to be fixed on the library side, not on my side, am i right?
fair-rose
fair-rose4w ago
I doubt there's a solution that makes everyone happy, but yes it's something we need to address that aside, why are you using reset to set data like this? just for demonstration purposes?
unwilling-turquoise
unwilling-turquoiseOP4w ago
no, i actually use it for my project
fair-rose
fair-rose4w ago
why manage it through a reset? What data are you assigning this way?
unwilling-turquoise
unwilling-turquoiseOP4w ago
the reset feature works fine in react-hook-form, but not in this library. I actually prefer this library, but I ran into this bug.
unwilling-turquoise
unwilling-turquoiseOP4w ago
This is the project I’m currently working on.
fair-rose
fair-rose4w ago
yeah, because RHF manages async default values differently. So in your case, you have a search element, and when it finds something, it populates a form with the order's data?
unwilling-turquoise
unwilling-turquoiseOP4w ago
Right now, the search only retrieves items and isn’t related to data ordering. I just want to reset the forms to the new defaultValues, but doing so makes the items disappear.
fair-rose
fair-rose4w ago
if you consistently change defaultValues, having them in state is the safer approach. setDefaultValues and then any form.reset will fall back to the latest state of defaultValues the temporary data of form.reset(data) (even without the bug) is not a good fit
unwilling-turquoise
unwilling-turquoiseOP4w ago
how about this, is it ok?
const [inboundsDefaultValues, setInboundsDefaultValues] = useState<
CreateInboundBatch['inbounds']
>([]);

...

const { data, error } = await searchPurchaseOrderDetails({
purchaseOrderId: value,
});

...

// Automatically create form entries for all items
const inbounds: CreateInboundBatch['inbounds'] = (data || []).map(
(item) => ({
purchaseOrderId: item.purchaseOrderId,
itemId: item.itemId,
description: item.description,
quantity: 0,
reject: null,
saveToQuarantine: null,
batchNumber: '',
expiredDate: null,
date: new Date(),
receiver: '',
receiverPosition: '',
photo: null,
})
);

setInboundsDefaultValues(inbounds);

form.reset({ inbounds }, { keepDefaultValues: true });

...

<form.AppField name='inbounds' mode='array'>
{(field) => {
const inbounds = field.state.value;

return (
<>
<div className='mb-4 flex items-center justify-between'>
<h1 className='text-2xl font-bold'>Entry Inbound (Batch)</h1>
<Button
type='button'
onClick={() => {
form.reset(
{ inbounds: inboundsDefaultValues },
{ keepDefaultValues: true }
);
}}
>
Reset All
</Button>
</div>

...

</>
);
}}
</form.AppField>
const [inboundsDefaultValues, setInboundsDefaultValues] = useState<
CreateInboundBatch['inbounds']
>([]);

...

const { data, error } = await searchPurchaseOrderDetails({
purchaseOrderId: value,
});

...

// Automatically create form entries for all items
const inbounds: CreateInboundBatch['inbounds'] = (data || []).map(
(item) => ({
purchaseOrderId: item.purchaseOrderId,
itemId: item.itemId,
description: item.description,
quantity: 0,
reject: null,
saveToQuarantine: null,
batchNumber: '',
expiredDate: null,
date: new Date(),
receiver: '',
receiverPosition: '',
photo: null,
})
);

setInboundsDefaultValues(inbounds);

form.reset({ inbounds }, { keepDefaultValues: true });

...

<form.AppField name='inbounds' mode='array'>
{(field) => {
const inbounds = field.state.value;

return (
<>
<div className='mb-4 flex items-center justify-between'>
<h1 className='text-2xl font-bold'>Entry Inbound (Batch)</h1>
<Button
type='button'
onClick={() => {
form.reset(
{ inbounds: inboundsDefaultValues },
{ keepDefaultValues: true }
);
}}
>
Reset All
</Button>
</div>

...

</>
);
}}
</form.AppField>
Do I need to make any adjustments? The “Reset All” button already works as expected, though.
fair-rose
fair-rose4w ago
Looks fine. I assume the setInboundsDefaultValues is part of a function and not called on every render. The part that I suggested would look like this:
const form = useAppForm({
defaultValues: { inbounds: inboundsDefaultValues }
})

// Reset calls (get last set defaultValue
form.reset()

// Reset to new values
setInboundsDefaultValues(inbounds)
form.reset()
const form = useAppForm({
defaultValues: { inbounds: inboundsDefaultValues }
})

// Reset calls (get last set defaultValue
form.reset()

// Reset to new values
setInboundsDefaultValues(inbounds)
form.reset()
that way, you keep track for subsequent resets (and still allow async initial values which keepDefaultValues would block)
unwilling-turquoise
unwilling-turquoiseOP4w ago
Thank you Luca for helping me! please let me know once this bug is fixed.😁
fair-rose
fair-rose4w ago
will do! It's a tough one to crack internally, so this can take a while
unwilling-turquoise
unwilling-turquoiseOP4w ago
anyway, i'm not be able to do that if i'm using withForm() right?
fair-rose
fair-rose4w ago
Why not? withForm is a HOC so that the form prop it accepts is properly typed
unwilling-turquoise
unwilling-turquoiseOP4w ago
right now the defautValues is in shared.ts, shared for parent (useForm) and child (withForm) component, how do i put useState value in defaultValues?
// shared.ts
import type { CreateInboundBatch } from '@repo/schema';
import { formOptions } from '@tanstack/react-form';

export const formOpts = formOptions({
defaultValues: {
inbounds: [] as CreateInboundBatch['inbounds'],
},
});

// page.tsx
import { formOpts } from './shared';

const form = useAppForm({
...formOpts,
...
})

// form.tsx
import { formOpts } from './shared';

export const Form = withForm({
...formOpts,
...
})
// shared.ts
import type { CreateInboundBatch } from '@repo/schema';
import { formOptions } from '@tanstack/react-form';

export const formOpts = formOptions({
defaultValues: {
inbounds: [] as CreateInboundBatch['inbounds'],
},
});

// page.tsx
import { formOpts } from './shared';

const form = useAppForm({
...formOpts,
...
})

// form.tsx
import { formOpts } from './shared';

export const Form = withForm({
...formOpts,
...
})
fair-rose
fair-rose4w ago
with the spread, you can overwrite it in your form hook:
const form = useAppForm({
...formOpts,
defaultValues: incomingData ?? formOpts.defaultValues
})
const form = useAppForm({
...formOpts,
defaultValues: incomingData ?? formOpts.defaultValues
})
unwilling-turquoise
unwilling-turquoiseOP4w ago
so how it look like in withForm() component?
fair-rose
fair-rose4w ago
withForm is a HOC, so at runtime, the defaultValues you passed it no longer exist they serve to type your form prop, not to be actual values to use withFieldGroup does need them at runtime, but only to know what Object.keys it needs to map. The values of it are irrelevant
unwilling-turquoise
unwilling-turquoiseOP4w ago
i think i get it now. thank you 🙂
fair-rose
fair-rose4w ago
If it makes it clearer. With all the details taken away, withForm is this:
function withForm(options) {
return options.render
}

const MyComponent = withForm({
...formOpts,
render: function Render() {}
})
function withForm(options) {
return options.render
}

const MyComponent = withForm({
...formOpts,
render: function Render() {}
})
unwilling-turquoise
unwilling-turquoiseOP4w ago
working as expected, thanks again Luca! now i just call form.reset() without passing any arguments

Did you find this page helpful?