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:
-
User action triggers data change:
count
changes from 0 to 1 -
First render:
-
usePrevious
returnsprevious.current
(still the previous value orundefined
) - Render completes, showing correct comparison data
-
-
useEffect executes:
previous.current = 1
(updates to latest value) - Additional render is triggered: Due to real-time update logic, component re-renders
-
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!
- Now
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:
-
Direct processing during render: Doesn’t depend on asynchronous
useEffect
execution - Atomic operations: Completes previous value saving and current value updating in the same render
- 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