On replacing componentDidUpdate and other React life cycle methods with hooks

Josh Frank
CodeX
Published in
5 min readSep 8, 2021

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, buttons, 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 in previousRef.current. We also check if previousRef.current is undefined so we don’t open our modal when the page is first loaded.
  • This useEffect() returns a cleanup function, defining any logic which needs to run before a components unmounts/updates — the equivalent of componentWillUnmount. This cleanup function updates our previousRef, so the component can hang onto references just like it could with previousProps.
  • 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.

--

--

Josh Frank
CodeX
Writer for

Oh geez, Josh Frank decided to go to Flatiron? He must be insane…