T
TanStack4mo ago
deep-jade

reset form state within dialog without useEffect?

I'm not exactly sure this is a direct issue with this form library, but i've been struggling on this a while, so gonna post: i have a form inside a dialog. the dialog is pretty standard and tracks it's own open/close state. whenever it closes i would like to have it reset the form, so in the openChange prop i say something like if (closing) form.reset(). for some reason though, this doesn't actually reset the form. i assume it's something to do with the render vs state update cycle? here's some of the relevant code cut down. is there something obvious going wrong here?
export function CreateExpenseDialog() {
const create = useCreateExpense();
const route = SearchRoute.useSearchRoute();
const form = useForm({
create: create,
close: route.close,
open: route.open,
});

return (
<Dialog
open={route.view() === "open"}
onOpenChange={(opening) => {
if (opening) {
route.open();
} else {
form.api.reset();
route.close();
}
}}
>
<form onSubmit={prevented(() => void form.api.handleSubmit())}>
<form.api.AppField
name="description"
children={(field) => (
<field.TextField
label="Description"
inputProps={{
placeholder: "enter description",
}}
/>
)}
/>
<form.api.AppForm>
<form.api.SubmitButton>Submit</form.api.SubmitButton>
<form.api.CancelButton onClick={route.close}>Cancel</form.api.CancelButton>
<FieldsErrors
className="col-span-full min-h-32 "
metas={Object.values(form.metas)}
/>
</form.api.AppForm>
</form>
</Dialog>
);
}
export function CreateExpenseDialog() {
const create = useCreateExpense();
const route = SearchRoute.useSearchRoute();
const form = useForm({
create: create,
close: route.close,
open: route.open,
});

return (
<Dialog
open={route.view() === "open"}
onOpenChange={(opening) => {
if (opening) {
route.open();
} else {
form.api.reset();
route.close();
}
}}
>
<form onSubmit={prevented(() => void form.api.handleSubmit())}>
<form.api.AppField
name="description"
children={(field) => (
<field.TextField
label="Description"
inputProps={{
placeholder: "enter description",
}}
/>
)}
/>
<form.api.AppForm>
<form.api.SubmitButton>Submit</form.api.SubmitButton>
<form.api.CancelButton onClick={route.close}>Cancel</form.api.CancelButton>
<FieldsErrors
className="col-span-full min-h-32 "
metas={Object.values(form.metas)}
/>
</form.api.AppForm>
</form>
</Dialog>
);
}
21 Replies
deep-jade
deep-jadeOP4mo ago
hook:
// hook
function useForm(options: UseFormOptions) {
const api = useAppForm({
defaultValues: { description: "" },
validators: { onChange: schema },
onSubmit: async (fields) => {
options.close();

const promise = withToast({
promise: () => options.create(fields.value.description),
notify: { ... },
}).catch(() => { options.open(); });

return promise;
},
});

const metas = useStore(api.store, (store) => store.fieldMeta);

return { api, metas };
}
// hook
function useForm(options: UseFormOptions) {
const api = useAppForm({
defaultValues: { description: "" },
validators: { onChange: schema },
onSubmit: async (fields) => {
options.close();

const promise = withToast({
promise: () => options.create(fields.value.description),
notify: { ... },
}).catch(() => { options.open(); });

return promise;
},
});

const metas = useStore(api.store, (store) => store.fieldMeta);

return { api, metas };
}
the thing that's really throwing me for a loop is, the form error does go away if you do the following - blur the input with a click within the modal, and then close the modal by clicking outside of it does not go away if you - click outside of the modal before otherwise invoking blur of the input i've quickly concluded that i think the blur validation is happening after the modal closes and its state update runs. anyway around this? been playing with this for a bit.. a bit confused it seems like basically even though i reset the form, the field metas don't reset with it. the only way i could get this to consistently work is by wrapping the whole form in a check that the modal is open, but i don't even follow why this works. at a loss at this point
flat-fuchsia
flat-fuchsia4mo ago
does the dialog not completely unrender the form? I think the reason your solution with the conditional render works may be because it forces the form to completely remount (and thus reset to the defaultValues). So the reset doesn't work, but the rerender creates a new form which never had modified values
deep-jade
deep-jadeOP4mo ago
but the form that's in the condition should be isolated from my form instance, right? ie. the lib is headless, so if just the UI is wrapped in a conditional check, shouldn't that not affect the state at all?
flat-fuchsia
flat-fuchsia4mo ago
depends on if the component wrapping the form is unmounted, which also gets rid of the hook unless that's not what you meant by the conditional render solution
deep-jade
deep-jadeOP4mo ago
true. that's probably happening. but it's weird to me that i don't get the same behavior without the condition. if the wrapping component is also unmounting, then a conditional check inside that component shouldn't affect anything?
export function CreateExpenseDialog() {
const create = useCreateExpense();
const route = SearchRoute.useSearchRoute();
const form = useForm({
create: create,
close: route.close,
open: route.open,
});

useEffect(() => {
if (route.view() === "closed") {
form.api.reset();
}
}, [route.view()]);

return (
<Dialog
open={route.view() === "open"}
onOpenChange={(opening) => {
if (opening) {
route.open();
} else {
route.close();
}
}}
>
<DialogHeader className="sr-only">
<DialogTitle>Create New Group</DialogTitle>
</DialogHeader>
<DialogContent
aria-describedby={undefined}
omitCloseButton
>
{route.view() === "open" && (
<form onSubmit={prevented(() => void form.api.handleSubmit())}>
<form.api.AppField
name="description"
children={(field) => (
<field.TextField
label="Description"
inputProps={{
placeholder: "enter description",
}}
/>
)}
/>
<form.api.AppForm>
<form.api.SubmitButton>Submit</form.api.SubmitButton>
<form.api.CancelButton onClick={route.close}>
Cancel
</form.api.CancelButton>
</form.api.AppForm>
<FieldsErrors
className="col-span-full min-h-32 "
metas={Object.values(form.metas)}
/>
</form>
)}
</DialogContent>
</Dialog>
);
}
export function CreateExpenseDialog() {
const create = useCreateExpense();
const route = SearchRoute.useSearchRoute();
const form = useForm({
create: create,
close: route.close,
open: route.open,
});

useEffect(() => {
if (route.view() === "closed") {
form.api.reset();
}
}, [route.view()]);

return (
<Dialog
open={route.view() === "open"}
onOpenChange={(opening) => {
if (opening) {
route.open();
} else {
route.close();
}
}}
>
<DialogHeader className="sr-only">
<DialogTitle>Create New Group</DialogTitle>
</DialogHeader>
<DialogContent
aria-describedby={undefined}
omitCloseButton
>
{route.view() === "open" && (
<form onSubmit={prevented(() => void form.api.handleSubmit())}>
<form.api.AppField
name="description"
children={(field) => (
<field.TextField
label="Description"
inputProps={{
placeholder: "enter description",
}}
/>
)}
/>
<form.api.AppForm>
<form.api.SubmitButton>Submit</form.api.SubmitButton>
<form.api.CancelButton onClick={route.close}>
Cancel
</form.api.CancelButton>
</form.api.AppForm>
<FieldsErrors
className="col-span-full min-h-32 "
metas={Object.values(form.metas)}
/>
</form>
)}
</DialogContent>
</Dialog>
);
}
this is what i have that's working somehow
flat-fuchsia
flat-fuchsia4mo ago
a reproducible issue on stackblitz could help for testing. Could you list the imports required for it?
deep-jade
deep-jadeOP4mo ago
I'll get back to this tomorrow hopefully with a repro. https://github.com/jackbisceglia/blank/blob/main/packages/web/src/pages/_protected/%40create-expense.tsx this is the component in question, though the version of it that i have currently works slightly differently with the form not lifted into its own component
flat-fuchsia
flat-fuchsia4mo ago
Sounds good! form.reset currently has some issues with the ordering of updates in React, so this could be a case for that. The main issue with it is being tracked in this PR
deep-jade
deep-jadeOP4mo ago
i think i've tracked down the issue. no repro so i can try to put it together if nothing makes sense here but basically: when i leave the modal by clicking outside of it, the following happen in order - close - effect that resets form - form blur event runs only the children of the modal re-render, so form state persists i'm not totally sure why this happens though. when i close the form by clicking a button that closes it, the following order happens: - form blur event runs - close - effect that resets form
flat-fuchsia
flat-fuchsia4mo ago
that sounds logical! See it this way: When you leave the modal by clicking outside of it * The modal component captures your click and sees you clicked outside * It runs the hiding event which includes your form.reset * Your previously focused field is removed, so it's blurred and runs the onBlur event * This runs blur validation When you leave the modal with the close button * Your focus switches from a field to the button, which runs the onBlur event * This runs blur validation * The button's onClick is triggered, which closes the modal * The closing modal executes your form.reset so assuming it's this different condition when everything runs in sync, you can escape the form reset to be outside of the script. Give this a try:
- form.reset()
+ setTimeout(() => form.reset(), 0)
- form.reset()
+ setTimeout(() => form.reset(), 0)
deep-jade
deep-jadeOP4mo ago
this inside the effect? so basically it pushes the reset to the end of teh call stack it works. though i see a quick validation error during the modal close. not a huge deal i suppose
flat-fuchsia
flat-fuchsia4mo ago
wait, from what? closing the modal while you have an invalid form?
deep-jade
deep-jadeOP4mo ago
yea. because the field is required. it's correct behavior and happens in all cases because the field blur occurs before the modal is done closing i think this is fine for now. thanks, the setTimeout works nicely
flat-fuchsia
flat-fuchsia4mo ago
do you have access to the modal state inside the component? if so, you can add in a condition to not execute it if it's closing
deep-jade
deep-jadeOP4mo ago
right. brings me back to wrapping the entire form in an isOpen check seems to solve the issue as well. i solved the issue of dialog re-rendering on each keystroke now, so this makes more logical sense now i think: - close() - close state updates - re-render before blur can run? - because no blur occurred, the field never entered error state ah but that won't work if blur does end up occurring so i still need the effect ignore me !
flat-fuchsia
flat-fuchsia4mo ago
oh, I meant
validators: {
onBlur: ({ formApi }) => {
if (isClosing) return;
return formApi.parseValuesWithSchema(yourSchema)
}
}
validators: {
onBlur: ({ formApi }) => {
if (isClosing) return;
return formApi.parseValuesWithSchema(yourSchema)
}
}
not sure if the state update will happen before the blur validation. Could be another case where the close button will have weird behaviour
deep-jade
deep-jadeOP4mo ago
yep that works
flat-fuchsia
flat-fuchsia4mo ago
in both cases?
deep-jade
deep-jadeOP4mo ago
well it won't work for cancel but i think that makes sense sorry cancel === close button
flat-fuchsia
flat-fuchsia4mo ago
hmmm, yeah I think there's no easy way around that
deep-jade
deep-jadeOP4mo ago
i can tolerate a situation where interacting with another part of the form without filling in the required field shows an error, that feels fair. the clickign outside felt awkward yea modals have caused me immense pain

Did you find this page helpful?