React 19+NextJS中useFormState表单重置及弹窗二次打开问题
问题解决方案
问题分析
- 表单错误未重置: 关闭弹窗后再次打开,之前的验证错误依然显示。原因是
useFormState保留了之前的状态,且代码中直接修改了不可变的state对象(state.message = ''),这是无效操作。 - 提交成功后无法重新打开弹窗: 提交成功后,点击"Add card"按钮无法打开弹窗。原因是原
useEffect监听state和open,当弹窗重新打开时,state仍处于success状态,触发setOpen(false)导致弹窗立即关闭。
解决方案
核心修复点
- 通过
key重置表单状态: 当弹窗关闭时,修改表单的key属性,强制React重新挂载表单组件,从而重置useFormState到初始状态。 - 移除无效的状态修改: 不要直接修改
useFormState返回的state对象,它是不可变的。 - 修正弹窗关闭逻辑: 移除导致弹窗自动关闭的错误
useEffect,改为在提交成功后关闭弹窗,并通过表单key重置状态。 - 修复错误提示的 typo: 价格字段的错误提示引用了
hours的错误,改为price。
修正后的CreateCardDialog组件代码
'use client' import { createCard } from '@/app/u/cards/actions' import SubmitButton from '@/components/submitbutton' import { Button } from '@/components/ui/button' import { Dialog, DialogClose, DialogContent, DialogTrigger, } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Switch } from '@/components/ui/switch' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip' import { Tables } from '@/types/supabase' import { InfoCircledIcon } from '@radix-ui/react-icons' import { Euro } from 'lucide-react' import Link from 'next/link' import { useEffect, useRef, useState } from 'react' import { useFormState } from 'react-dom' type CreateClientDialogProps = { children: React.ReactNode clients: Tables<'clients'>[] | null } const initialState = undefined const CreateCardDialog = ({ children, clients }: CreateClientDialogProps) => { const [open, setOpen] = useState(false) const formRef = useRef<HTMLFormElement>(null) const [state, formAction] = useFormState(createCard, initialState) const [customEndDate, setCustomEndDate] = useState(false) // 用于重置表单的key,每次弹窗关闭时递增 const [formKey, setFormKey] = useState(0) const handleSwitchChange = () => { setCustomEndDate(!customEndDate) } const today = new Date() const oneYearFromNow = new Date( today.getFullYear() + 1, today.getMonth(), today.getDate() ) const formattedDate = oneYearFromNow.toISOString().split('T')[0] // 提交成功后关闭弹窗 useEffect(() => { if (state?.status === 'success') { setOpen(false) } }, [state]) return ( <> {open ? <p className='text-lg'>JA</p> : <p className='text-lg'>NEE</p>} <Dialog open={open} onOpenChange={(newOpen) => { setOpen(newOpen) // 弹窗关闭时,递增formKey重置表单状态 if (!newOpen) { setFormKey(prev => prev + 1) } }} > <DialogTrigger asChild>{children}</DialogTrigger> <DialogContent> <p> {clients && clients.length === 0 ? ( <div> Please add <Link href='/u/clients' className='border-b border-b-black'> clients </Link> first before you add a card. </div> ) : ( // 添加key属性,每次弹窗关闭时重新挂载表单 <form ref={formRef} action={formAction} key={formKey}> <div className='mb-4'> <Label htmlFor='client_id' className='mb-2'> Client </Label> <Select name='client_id' required> <SelectTrigger className='w-[240px]'> <SelectValue placeholder='Select client' /> </SelectTrigger> <SelectContent> {clients?.map((client) => ( <SelectItem key={client.id} value={client.id}> {client.name} </SelectItem> ))} </SelectContent> </Select> {state?.errors?.client_id && ( <p className='py-2 text-xs text-red-500'> {state.errors.client_id} </p> )} </div> <div className='mb-4'> <Label htmlFor='hours' className='mb-2'> Hours </Label> <Input type='number' name='hours' id='hours' required /> {state?.errors?.hours && ( <p className='py-2 text-xs text-red-500'> {state.errors.hours} </p> )} </div> <div className='mb-4'> <Label htmlFor='price' className='mb-2'> Price </Label> <div className='relative flex items-center max-w-2xl '> <Euro className='absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 transform' /> <Input type='number' name='price' id='price' required className='pl-6' /> {/* 修复typo:从hours改为price */} {state?.errors?.price && ( <p className='py-2 text-xs text-red-500'> {state.errors.price} </p> )} </div> </div> <div className='mb-4 flex flex-col'> <div className='flex items-center'> <Switch checked={customEndDate} className='mr-2' onCheckedChange={handleSwitchChange} /> <span className=''>Set custom end date</span> <TooltipProvider> <Tooltip> <TooltipTrigger asChild> <InfoCircledIcon className='ml-1' /> </TooltipTrigger> <TooltipContent> <p>By default cards are valid for one year</p> </TooltipContent> </Tooltip> </TooltipProvider> </div> {customEndDate ? ( <> <Label htmlFor='ends_at' className='my-2 mr-2'> Valid until </Label> <input aria-label='Date' type='date' id='ends_at' name='ends_at' required defaultValue={formattedDate} /> </> ) : ( <input type='hidden' aria-label='Date' id='ends_at' name='ends_at' required defaultValue={formattedDate} /> )} </div> <p aria-live='polite' className='sr-only'> {state?.message} </p> <div className='mb-4'> {/* 直接使用state中的message,无需额外状态 */} {state?.status === 'error' && state?.message && ( <p className='py-2 text-xs text-red-500'>{state.message}</p> )} </div> <DialogClose asChild> <Button variant='outline' className='mr-2'> Cancel </Button> </DialogClose> <SubmitButton normal='Add card' going='Adding card...' /> </form> )} </p> </DialogContent> </Dialog> </> ) } export default CreateCardDialog
关键改进说明
- 表单重置: 通过
formKey实现表单的重新挂载,确保每次打开弹窗时useFormState都从初始状态开始,解决了错误残留的问题。 - 状态处理: 移除了对
state对象的直接修改,改为依赖React的状态更新机制,避免无效操作。 - 弹窗逻辑: 修正了提交成功后弹窗无法重新打开的问题,移除了导致弹窗自动关闭的错误
useEffect,改为在提交成功后主动关闭弹窗,并通过formKey重置状态。 - 错误提示: 修复了价格字段错误提示的引用错误,确保显示正确的验证信息。
内容的提问来源于stack exchange,提问作者Santi
相关产品推荐
相关产品推荐

