Never `useEffect` again or YOU_WILL_BE_FIRED

Never-ish

ยท

3 min read

The study case

You Might Not Need an Effect was released, at the very least, a couple of years ago now. It has really helped! Dan did an amazing job and collected a bunch of feedback on Twitter while writing the whole section around `effect`s.

However, I keep seeing at least one incorrect and widely-spread usage of useEffects: server/local state synchronization.

const Edit = ({ data, onSave }) => {
  const [dynamicData, setDynamicData] = useState(data)

  useEffect(() => {
    setDynamicData(data) // Don't do this!
  }, [data])

  return (...)
}

The downsides

  1. Double rendering: data scheduled to be updated => new render => useEffect runs => dynamicData scheduled to be updated => new render.

  2. Synchronizing React state to React state is unnecessary.

  3. Dynamic/local data may be unexpectedly overwritten when/if static/props data changes unexpectedly. Imagine your user is filling a form and your useQuery's refetchInterval strikes; it would overwrite all the dynamic/local data your user may have input!

The fix

Adjusting some state when a prop changes hides the answer to this issue:

๐Ÿ’ก
Derive your state!

This is how you'd do it:

  1. Initialize your local/dynamic state as null.

     const Edit = ({ data: staticData }) => {
       const [dynamicData, setDynamicData] = useState(null)
       ...
     }
    
  2. Derive the actual component state:

     const Edit = ({ data: staticData, onSave }) => {
       const [dynamicData, setDynamicData] = useState(null)
       const data = dynamicData ?? staticData
       ...
     }
    

    This allows you to keep your state minimal. The premise is using the local/dynamic data only after there has been interaction, and otherwise use the props/static data. That makes null handy as an initial value.

  3. Use data as your source of truth, and update dynamicData through its state dispatcher function.

    • Use data as inputs' values.

    • Use setDynamicData to update those values.

    const Edit = ({ data: staticData, onSave }) => {
      const [dynamicData, setDynamicData] = useState(null)
      const data = dynamicData ?? staticData

      return (
        /** (Mostly) Always use `data` as your source of truth, and update `dynamicData` */      
      )
    }
  1. Don't forget to clear local/dynamic data as needed.

     const Edit = ({ data: staticData, onSave }) => {
       const [dynamicData, setDynamicData] = useState(null)
       const data = dynamicData ?? staticData
       const reinitializeState = () => {
         setDynamicData(null)
       }
       const handleSave = () => {
         onSave(data).then(reinitializeState)
       }
    
       return (
         /** (Mostly) Always use `data` as your source of truth, and update `dynamicData` */      
       )
     }
    

The benefits

  1. No double rendering: data changes, and the actual component state is derived during render.

    ๐Ÿ—’
    Since you'll need to reinitializeState after awaiting for onSave's promise to resolve, you'll kinda end up with two renders - but definitely faster! Technically, they could at least be batched if really concerned.
  2. Deriving state is simpler to reason about; no indirection.

  3. Predictably and on-demand reset your dynamic/local data.

ย