reactjsnext.jsreact-hooksreact-forms

Resetting form with useFormState (React 19) and NextJS


I am struggling with useFormStatus in React 19.

I am using it in my NextJS app.

This is my page.tsx

import { getCardsFromUser } from '@/app/u/cards/actions'
import { getClientsFromUser } from '@/app/u/clients/actions'
import Header from '@/components/header'
import { Button } from '@/components/ui/button'
import { requireUser } from '@/utils/auth'
import CreateCardDialog from './create'
import { DataTable } from './table'
import { columns } from './table/columns'

const CardsPage = async () => {
  requireUser()
  const { data: cards } = await getCardsFromUser()
  const { data: clients } = await getClientsFromUser()

  return (
    <>
      <Header title='Cards'>
        <CreateCardDialog clients={clients}>
          <Button>Add card</Button>
        </CreateCardDialog>
      </Header>
      <DataTable columns={columns} data={cards} />
    </>
  )
}

export default CardsPage

This is my 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

type ErrorType = string | undefined

const CreateCardDialog = ({ children, clients }: CreateClientDialogProps) => {
  const [open, setOpen] = useState(false)
  const formRef = useRef<HTMLFormElement>(null)
  const [state, formAction] = useFormState(createCard, initialState)
  const [errorMessage, setErrorMessage] = useState<ErrorType>(undefined)
  const [formData, setFormData] = useState<FormData>(new FormData())
  const [customEndDate, setCustomEndDate] = useState(false)

  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' && open === true) {
      setOpen(false)
      setErrorMessage(undefined)
    } else if (state?.status === 'error' && open === true) {
      setErrorMessage(state.message)
    } else if (state?.status === 'error' && open === false) {
      state.message = ''
      state.errors = {}
      setErrorMessage(undefined)
    }
  }, [state, open])

  return (
    <>
      {open ? <p className='text-lg'>JA</p> : <p className='text-lg'>NEE</p>}
      <Dialog open={open} onOpenChange={setOpen}>
        <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>
            ) : (
              <form ref={formRef} action={formAction}>
                <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'
                    />
                    {state?.errors?.hours && (
                      <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'>
                  {errorMessage && (
                    <p className='py-2 text-xs text-red-500'>{errorMessage}</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

It has a horrible useEffect but that was the only way if

  1. you click add card, you fill in the form wrong and it shows an error message
  2. you click cancel cause you don't wanna fill in the form anymore
  3. You change your mind and open the dialog again, but it still keeps showing the error.
  4. that was solved by that horrendous useEffect :) any ideas are welcome.

Now, the problem I am having is that after submitting the form successfully, I cannot click add card again. It does not open again. Probably also something with resetting ..

The code is on Github (/app/u/cards/...) https://github.com/santivdt/punchcards

It is also deployed here: https://punchcards.vercel.app/login where you can login with demo credentials to experience it firsthand :).

I did find this tutorial: https://www.robinwieruch.de/next-forms/, but I am learning and struggling to understand what is going on.

Maybe someone has an idea?

Thanks in advance.

Ps. when reading through the code here I think it might have to do with double nesting children? From Header to the Dialog? Maybe not ..


Solution

  • This answer to a related question points you in the right direction: https://stackoverflow.com/a/77816853/1077412

    To summarize, there doesn't seem to be a proper way to reset the state returned by useFormState. A workaround is to reset the whole component that renders the form by providing a different key prop. In your case, passing a different key to CreateCardDialog will reset its internal state (this means the dialogue will be closed, but that's fine as you only really want to reset the form when user user has either cancelled or successfully added a card).

    The following code changes should lead you to a working solution.

    Changes to page.tsx

    // You need to make this a client component in order to use `useState`.
    // If you don't want to make your whole page a client component, you need
    // an intermediate client component to hold `dialogKey` state
    'use client' 
    
    ...
    
      // Keep `dialogKey` in state and define a callback to change the key
      const [dialogKey, setDialogKey] = useState(0);
      const resetDialog = useCallback(() => setDialogKey(prevState => prevState + 1), []);
    
    ...
    
    return (
      // Add `onFinished` prop to CreateCardDialog to reset the whole component
      <CreateCardDialog key={dialogKey} clients={clients} onFinished={resetDialog}>
    
    ...
    

    Changes to create.tsx

    ...
    
    type CreateClientDialogProps = {
      children: React.ReactNode
      clients: Tables<'clients'>[] | null
      onFinished: () => void, // New prop
    }
    
    ...
    
    // Destructure the new prop
    const CreateCardDialog = ({ children, clients, onFinished }: CreateClientDialogProps) => {
    
    ...
    
      // Replace your "horrible" useEffect with this.
      // We just need to update error message when action status changes
      // and, if successful, call `onFinished` to reset the form and close the dialog
      useEffect(() => {
        setErrorMessage(state?.status === 'error' ? state?.message || 'Unknown error' : undefined)
        if (state?.status === 'success') onFinished();
      }, [onFinished, state?.message, state?.status])
    
      // Add this callback to be called when the dialog is open or closed.
      // If closed, call `onFinished` to reset the form
      const handleOpenChange = useCallback((newOpen: boolean) => {
        setOpen(newOpen);
        if (!newOpen) onFinished();
      }, [onFinished]);
    
    ...
    
    return (
        // Use your open-change callback
        <Dialog open={open} onOpenChange={handleOpenChange}>
    
    ...