A usePrevious Hook Disaster Story



This content originally appeared on DEV Community and was authored by reactuse.com

🚀 Explore more React Hooks possibilities? Visit www.reactuse.com for complete documentation and install via npm install @reactuses/core to supercharge your React development efficiency!

The Crime Scene

It was a peaceful Friday afternoon, and I was ready to leave early for the weekend when the operations manager rushed over in panic: “There’s something wrong with the page data! Users are complaining that the before-and-after comparison feature is completely broken!”

I opened the production environment and my heart sank. User feedback showed that the “Previous Visit Data” and “Current Visit Data” were displaying exactly the same values! This was a core feature of a financial product where users needed to compare data differences to make investment decisions.

What made it worse was that this feature had always worked perfectly in the test environment…

Emergency Investigation

After some urgent troubleshooting, I found the problem in a seemingly harmless custom Hook:

// Our usePrevious implementation
import { useEffect, useRef } from 'react'

export function usePrevious<T>(state: T): T | undefined {
  const ref = useRef<T>()

  useEffect(() => {
    ref.current = state
  })

  return ref.current
}

It was being used in the business code like this:

function DataComparisonPage() {
  const [currentData, setCurrentData] = useState(null)
  const previousData = usePrevious(currentData) // Get previous data

  // Page logic...

  return (
    <div>
      <div>Previous Data: {previousData?.value}</div>
      <div>Current Data: {currentData?.value}</div>
      <div>Change: {currentData?.value - previousData?.value}</div>
    </div>
  )
}

The logic looked completely fine! Why would this weird behavior occur?

Reproducing the Issue

After much investigation, I finally reproduced the problem locally. The key was that the production environment had some real-time update logic that would trigger additional renders after data changes:

function App() {
  const [count, setCount] = useState(0)
  const previous = useRef<number | null>(null);
  const [_, forceUpdate] = useState<number>(-1);

  useEffect(() => {
    console.log('count', count)
    previous.current = count;
  }, [count])

  // This simulates real-time update logic in production
  // In real projects, this could be WebSocket pushes, polling, etc.
  useEffect(() => {
    forceUpdate(Math.random())
  }, [count])

  console.log('previous', previous)

  return (
    <>
      <h1>Hello World</h1>
      <div className='card'>
        <button onClick={() => setCount((count) => count + 1)}>
          count is {count}
        </button>
        <p>previous is {previous.current}</p>
      </div>
    </>
  )
}

You can see the complete reproduction code in this playground.

Disaster Analysis

The root cause was in the execution timing:

  1. User action triggers data change: count changes from 0 to 1
  2. First render:
    • usePrevious returns previous.current (still the previous value or undefined)
    • Render completes, showing correct comparison data
  3. useEffect executes: previous.current = 1 (updates to latest value)
  4. Additional render is triggered: Due to real-time update logic, component re-renders
  5. Second render:
    • Now previous.current is already the latest value 1
    • Page displays: Previous Data = 1, Current Data = 1
    • User sees completely wrong comparison results!

In production environments, such additional renders might come from:

  • WebSocket push updates
  • Timer refreshes
  • Re-renders caused by other state management
  • Side effects from third-party libraries

The test environment didn’t have these complex interactions, so the problem was never discovered.

The Lifeline

After research and community discussions, I found the correct implementation:

import { useState } from 'react'

// Following these issues I think this is the best way to implement usePrevious:
// https://github.com/childrentime/reactuse/issues/115
// https://github.com/streamich/react-use/issues/2605
// https://github.com/alibaba/hooks/issues/2162
export function usePrevious<T>(value: T): T | undefined {
  const [current, setCurrent] = useState<T>(value)
  const [previous, setPrevious] = useState<T>()

  if (value !== current) {
    setPrevious(current)
    setCurrent(value)
  }

  return previous
}

The brilliance of this implementation lies in:

  1. Direct processing during render: Doesn’t depend on asynchronous useEffect execution
  2. Atomic operations: Completes previous value saving and current value updating in the same render
  3. Unaffected by additional renders: Logic remains consistent regardless of subsequent re-renders

Lessons Learned in Blood

After deploying the fix, functionality returned to normal, but this incident taught me a profound lesson:

Don’t use useRef to store state; use useState instead.

The problem with useRef is that it stores a mutable reference, and when multiple renders occur, this reference’s value might be modified at unpredictable times. useState, on the other hand, guarantees consistency and predictability of state updates.

This issue has also been discussed in the React community, as detailed in React Issue #25893, which thoroughly analyzes why the useRef + useEffect approach fails in complex scenarios.

Epilogue

This usePrevious disaster made me realize that choosing the right React API is more important than writing seemingly clever code. Sometimes, the simplest and most direct solution is often the most reliable.

Since then, I’ve been extra careful with any code involving state management and pay more attention to production environment monitoring and testing.

After all, nothing leaves a deeper impression than a production incident on a Friday afternoon…

References:


This content originally appeared on DEV Community and was authored by reactuse.com