On replacing componentDidUpdate and other React life cycle methods with hooks
I’ve addressed React’s life cycle before on this blog because it’s a common cause of acute nervous breakdown for many devs…
For a React engineer, having a deep understanding of component life cycle is like having an extra 15 inches if you’re a basketball player. And it’s especially helpful when refactoring class-based components to functional components — something I do just about all day long as a React engineer in 2021. It’s mostly just a matter of adjusting syntax, but what about life cycle methods like componentDidUpdate
or componentWillUnmount
?
Let’s take an honest-to-goodness real-life example from my work: a class Calculator
, which will be function Calculator()
when I’m through with it. It’s got lots of moving parts:
import { connect } from 'react-redux';
import { fetchPerformance } from '...redux actions...';class Calculator extends Component { state = {
isModalOpen: false,
selectedDateRangePrices: []
...more delicious state here...
}; componentDidUpdate = ( previousProps, previousState ) => {
if ( ...something about state/props changes ) {
this.setState( {
isModalOpen: true,
...more nice juicy state...
} );
}
}; render () {
return (
...a beautiful calculator component...
);
}}const mapStateToProps = (state) => ( {
start_date: state.closedEnds.start_date,
end_date: state.closedEnds.end_date,
starting_price: state.closedEnds.starting_price,
ending_price: state.closedEnds.ending_price,
ttl_return: state.closedEnds.ttl_return,
avg_annual: state.closedEnds.avg_annual,
time_calculated: state.closedEnds.time_calculated
} );export default connect( mapStateToProps, { fetchPerformance } )( Calculator );
In render()
we return
input
fields, button
s, and a modal which displays some useful information, hidden or displayed according to a boolean flag set in state. We also have to juggle some data from a Redux store along with our local state
.
I need to switch that isModalOpen
flag to true
only if certain attributes of props
and state
change; so, in class-based React, naturally I write that logic in componentDidUpdate
using previousProps
and previousState
. But… refactoring this to hook-based React isn’t a matter of straightforward character-for-character translation.
React’s docs aren’t very helpful here: they tell us we “can think of useEffect
as componentDidMount
, componentDidUpdate
, and componentWillUnmount
combined…” but useEffect
doesn’t give us anything helpful or familiar like previousProps
. So what do?
Here’s what the React docs should explain: Essentially, everything that happens to a component during its life cycle, between mount/update/unmount/s, can be described as the component’s “side effects” or just “effects” (hence the name). Class-based React handles these side effects with Component
methods and reference arguments — a little clunky and not very declarative. Functional React handles all side effects together in one hook, useEffect()
, and all references together in another hook, useRef()
. More elegant and React-ish.
Back in my class Calculator
, let’s say I want to open the modal whenever selectedDateRangePrices
changes in my component’s local state
:
// The modal opens if state.selectedDateRangePrices has changedcomponentDidUpdate = ( prevProps, prevState ) => {
if ( this.state.selectedDateRangePrices !== prevState.selectedDateRangePrices ) {
this.setState( { isModalOpen: true } );
}
}
It’s easy to translate this to hook-based React. Take care of state with the useState
hook; then, add a useEffect
that opens the modal in the body of its callback, and add state.selectedDateRangePrices
to its dependencies array:
function Calculator( { ...props galore... } ) { const [ state, setState ] = useState( {
isModalOpen: false,
selectedDateRangePrices: []
...state, wonderful state...
} ); const { start_date, end_date, starting_price, ending_price, ttl_return, avg_annual, time_calculated } = useSelector( state => state.openEnds ); useEffect( () => setState( { ...state, isModalOpen: true } ), [ state.selectedDateRangePrices ] );}
Here’s where it gets trickier: what if I only want to set state and open that modal if the props from Redux change? How about even trickier conditions, like only opening it when three or more have changed? Or when both of two particular ones have changed?
// The modal only opens if both ttl_return and avg_annual have changedcomponentDidUpdate = ( prevProps, prevState ) => {
if ( this.props.ttl_return !== prevProps.ttl_return && this.props.avg_annual !== prevProps.avg_annual ) {
this.setState( { isModalOpen: true } );
}
}
We don’t have the prevProps
reference from componentDidUpdate
, so to translate a component like this to hooks, I make my own reference with React’s useRef
hook. I’m not the first or the only one to say it: useRef
is the unsung hero of React hooks. Think of refs as little storage boxes that hold onto anything — fetch
results, DOM elements, other variables — that the engineer (you) can call upon using the attribute .current
. Refs are incredibly useful for situations like this where we’re fine-tuning a component’s life-cycle with elements or data from outside that component’s side effects:
function Calculator( { ...props galore... } ) { const [ state, setState ] = useState( {
isModalOpen: false,
selectedDateRangePrices: []
...state, wonderful state...
} ); const { start_date, end_date, starting_price, ending_price, ttl_return, avg_annual, time_calculated } = useSelector( state => state.openEnds ); const previousRef = useRef(); useEffect( () => {
if ( previousRef.current ) {
if ( ttl_return !== previousRef.current.ttl_return && avg_annual !== previousRef.current.avg_annual ) {
setState( { ...state, isModalOpen: true } )
}
}
return () => {
previousRef.current = { ttl_return, avg_annual };
};
} );}
What’s going on here?
- We start by defining
previousRef
. - We write a
useEffect()
, which runs when a component is first mounted or whenever it updates. Inside, the callback checks if our condition is met — i.e., if both current Redux props are equal to their counterparts inpreviousRef.current
. We also check ifpreviousRef.current
isundefined
so we don’t open our modal when the page is first loaded. - This
useEffect()
return
s a cleanup function, defining any logic which needs to run before a components unmounts/updates — the equivalent ofcomponentWillUnmount
. This cleanup function updates ourpreviousRef
, so the component can hang onto references just like it could withpreviousProps
. - The logic we defined, and the cleanup function we
return
, means we don’t need a dependencies array.
You can also create a custom hook with useRef
inside — called usePrevious
, maybe — but 1) that’s unnecessary and 2) that requires giving up a lot of the control and specificity that makes useRef
so powerful.
On that note, consider yourself warned: useRef
is not a substitute for good React design! It’s awesome to stretch beyond the normal flow of props
from parents to children… but, stretch too far beyond it and you’ll end up with some very wacky bugs or, at least, indecipherable code that’s a nightmare to edit. Every reference should have clear logic and a compelling reason for existence— lacking either is a clear sign to find something within the component’s side effects to get what you need, or consider restructuring your code.