reactjsnext.js

useOptimistic hook bug


I'm getting a weird behavior when using the useOptimistic hook from React.

Running:
"next": "^14.0.4" with app router.

When I click the button I get 2 console log outputs instantly.

In the first log I can see the optimistic result:

{
    "__typename": "GuideBlocksFormWebsite",
    "id": "1",
    "value": true,
    "pending": true
}

And then instantly directly after it's reverted to the init state as seen in the second log (not the desired state as the value should be updated to true):

{
    "__typename": "GuideBlocksFormWebsite",
    "id": "1",
    "value": false
}

SSRPage:

import { cookies } from 'next/headers'
import { createClient } from '@/utils/supabase/server'
import { Form } from './Form'

export const dynamic = 'force-dynamic'

const SSRPage = async () => {
  const cookieStore = cookies()
  const supabase = createClient(cookieStore)

  const { data: form } = await supabase.from('forms_progress').select()

  return (
    <>
      <Form initFormData={form} />
    </>
  )
}

export default SSRPage

initFormData looks like this:

form: [
  {
    id: '97b1672d-b119-473e-958c-8d1c0902fc09',
  }
]

Form:

'use client'

import { useOptimistic, useState } from 'react'
import TickButton from './TickButton'

const keysToRender = ['a', 'b', 'c', 'd', 'e', 'f']

export const Form = ({ initFormData }) => {
  const [form, setForm] = useState(initFormData[0])
  const [optimisticTick, addOptimisticTick] = useOptimistic(form, (state, newValue) => {
    return {
      ...state,
      [newValue.key]: newValue.value,
    }
  })

  const renderButtons = () => {
    return keysToRender.map((key) => (
      <TickButton
        key={key}
        id={key}
        value={optimisticTick[key]}
        addOptimisticTick={addOptimisticTick}
      />
    ))
  }

  return <>{optimisticTick && renderButtons()}</>
}

TickButton:

'use client'

import { useTransition } from 'react'
import useGuideForm from '@/app/hooks/useGuideForm'
import { useRouter } from 'next/navigation'

const TickButton = ({ id, value, addOptimisticTick }) => {
  const { updateFormsProgress } = useGuideForm()
  const [, startTransition] = useTransition()
  const router = useRouter()

  const handleOnClick = async () => {
    startTransition(() => {
      addOptimisticTick({ key: id, value: true, pending: true })
    })

    await updateFormsProgress({
      [id]: true,
    })

    router.refresh()
  }

  return (
    <button type="button" onClick={handleOnClick} disabled={value === true}>
      {value ? 'Ready' : 'Confirm'}
    </button>
  )
}

export default TickButton

updateFormsProgress:

  const updateFormsProgress = async (data: unknown) => {
    try {
      if (user) {
        const { error } = await supabase
          .from('forms_progress')
          .update(data)
          .eq('id', user?.id as string)

        if (error) {
          throw new Error(error.message)
        }
      }
    } catch (error) {
      captureException(error)
    }
  }

Uploaded video here of the behavior: video

The flow:

Do you have any clues to what's going on here?


Solution

  • I encountered a similar issue while attempting to implement the useOptimistic hook without resorting to server actions, revalidateTag, and 'no-cache'. While I can't pinpoint why your code isn't functioning as expected, it appears as though it should. I'm not sure if it potentially could be some kind of caching issue.

    However, a potential resolution could involve the following steps:

    1. Implement a server action:
    'use server'
    
    import { revalidateTag } from 'next/cache'
    
    export const mutateFormProgress = async (keyToUpdate, rowId) => {
      await fetch(
        `http://localhost:3000/api/form-progress?rowId=${rowId}`,
        {
          method: 'POST',
          cache: 'no-cache',
          body: JSON.stringify({
            keytoUpdate,
          }),
          headers: {
            'Content-type': 'application/json',
          },
        }
      )
    
      revalidateTag('form-progress')
    }
    
    1. Modify the onClick handler:
    const handleOnClick = async () => {
      startTransition(() => {
        addOptimisticTick({ key: id, value: true, pending: true })
      })
    
      await mutateFormProgress(keyToUpdate, rowId)
    }
    
    1. Perform the fetch call on the SSR page:
    const response = await fetch(
      `http://localhost:3000/api/form-progress}`,
      {
        cache: 'no-cache',
        next: {
          tags: ['form-progress'],
        },
      }
    )
    const formData = await response.json()
    

    Additionally, it's essential to create an API endpoint, e.g., api/form-progress, with endpoints for both fetching (GET) and mutating (POST) data from the database table.