Thinking in React Hooks

React introduced hooks one year ago, and they've been a game-changer for a lot of developers. There are tons of how-to introduction resources out there, but I want to talk about the fundamental mindset change when switching from React class components to function components + hooks.

Class component
Function component

With class components, we tie updates to specific lifecycle events.

In a function component, we instead use the useEffect hook to run code during the major lifecycle events.

Class component

With class components, we tie updates to specific lifecycle events.

class Chart extends Component {
componentDidMount() {
// when Chart mounts, do this
}
componentDidUpdate(prevProps) {
if (prevProps.data == props.data) return
// when data updates, do this
}
componentWillUnmount() {
// before Chart unmounts, do this
}
render() {
return (
<svg className="Chart" />
)
}
}
Function component

In a function component, we instead use the useEffect hook to run code during the major lifecycle events.

const Chart = ({ data }) => {
useEffect(() => {
// when Chart mounts, do this
// when data updates, do this
return () => {
// when data updates, do this
// before Chart unmounts, do this
}
}, [data])
return (
<svg className="Chart" />
)
}

Great! At this point, you might be thinking:

Got it, so useEffect() is just a new way to hook into lifecycle events!

but that's the wrong way to look at it.

Hmm, let's look at a concrete example to see what the difference is. For example, what if we have a computationally expensive getDataWithinRange() function that returns a filtered dataset, based on a specified dateRange? Because getDataWithinRange() takes some time to run, we'll want to store it in our component's state object and only update it when dateRange changes.

Class component
Function component

With lifecycle events, we need to deal with all changes in one spot. Our thinking looks something like:

When our component loads, and when props change (specifically dateRange), update data

In a function component, we need to think about what values stay in-sync. Each update flows more like the statement:

Keep data in sync with dateRange

Class component

With lifecycle events, we need to deal with all changes in one spot. Our thinking looks something like:

When our component loads, and when props change (specifically dateRange), update data

class Chart extends Component {
state = {
data: null,
}
componentDidMount() {
const newData = getDataWithinRange(this.props.dateRange)
this.setState({data: newData})
}
componentDidUpdate(prevProps) {
if (prevProps.dateRange != this.props.dateRange) {
const newData = getDataWithinRange(this.props.dateRange)
this.setState({data: newData})
}
}
render() {
return (
<svg className="Chart" />
)
}
}
Function component

In a function component, we need to think about what values stay in-sync. Each update flows more like the statement:

Keep data in sync with dateRange

const Chart = ({ dateRange }) => {
const [data, setData] = useState()
useEffect(() => {
const newData = getDataWithinRange(dateRange)
setData(newData)
}, [dateRange])
return (
<svg className="Chart" />
)
}

See how much easier it is to wrap your head around the concept of keeping variables in-sync?

Class component
Function component

In fact, this last example was still thinking inside the class-component box. We're storing data in state to prevent re-calculating it every time our component updates.

But we no longer need to use state! Here to the rescue is useMemo(), which will only re-calculate data when its dependency array changes.

Class component

class Chart extends Component {
state = {
data: null,
}
componentDidMount() {
const newData = getDataWithinRange(this.props.dateRange)
this.setState({data: newData})
}
componentDidUpdate(prevProps) {
if (prevProps.dateRange != this.props.dateRange) {
const newData = getDataWithinRange(this.props.dateRange)
this.setState({data: newData})
}
}
render() {
return (
<svg className="Chart" />
)
}
}
Function component

In fact, this last example was still thinking inside the class-component box. We're storing data in state to prevent re-calculating it every time our component updates.

But we no longer need to use state! Here to the rescue is useMemo(), which will only re-calculate data when its dependency array changes.

const Chart = ({ dateRange }) => {
const data = useMemo(() => (
getDataWithinRange(dateRange)
), [dateRange])
return (
<svg className="Chart" />
)
}

Thinking about updates in-context

Not impressed yet? Fine.
Here is where this concept really shines.

Class component
Function component

Imagine that we have many values that we need to calculate, but they depend on different props. For example, we need to calculate:

  • our data when our dateRange changes,
  • the dimensions of a chart when the margins change, and
  • our scales when our data changes

In our function component, we can focus on our simple statements, like:

Keep dimensions in sync with margins
Class component

Imagine that we have many values that we need to calculate, but they depend on different props. For example, we need to calculate:

  • our data when our dateRange changes,
  • the dimensions of a chart when the margins change, and
  • our scales when our data changes
class Chart extends Component {
state = {
data: null,
dimensions: null,
xScale: null,
yScale: null,
}
componentDidMount() {
const newData = getDataWithinRange(this.props.dateRange)
this.setState({data: newData})
this.setState({dimensions: getDimensions()})
this.setState({xScale: getXScale()})
this.setState({yScale: getYScale()})
}
componentDidUpdate(prevProps, prevState) {
if (prevProps.dateRange != this.props.dateRange) {
const newData = getDataWithinRange(this.props.dateRange)
this.setState({data: newData})
}
if (prevProps.margins != this.props.margins) {
this.setState({dimensions: getDimensions()})
}
if (prevState.data != this.state.data) {
this.setState({xScale: getXScale()})
this.setState({yScale: getYScale()})
}
}
render() {
return (
<svg className="Chart" />
)
}
}
Function component

In our function component, we can focus on our simple statements, like:

Keep dimensions in sync with margins
const Chart = ({ dateRange, margins }) => {
const data = useMemo(() => (
getDataWithinRange(dateRange)
), [dateRange])
const dimensions = useMemo(getDimensions, [margins])
const xScale = useMemo(getXScale, [data])
const yScale = useMemo(getYScale, [data])
return (
<svg className="Chart" />
)
}

See how unwieldy our class component got, even in our simple example?

This is because we had a lot of declarative code explaining how keep our variables in-sync with props and state, and in our function component, we get to focus only on what to keep in-sync.

Notice how we can use as many useMemo() hooks as we want - letting us keep the dependencies and effects as close as possible.

Looser definition of state

Okay! Moving on -- what if our scales needed to change based on the dimensions of our chart?

Class component
Function component

In our class component, we'll need to compare our prevState and current state.

Our hooks' dependency arrays don't care whether our margins or dimensions are in our props, state, or neither - a value is a value, as far as they're concerned.

Class component

In our class component, we'll need to compare our prevState and current state.

class Chart extends Component {
state = {
data: null,
dimensions: null,
xScale: null,
yScale: null,
}
componentDidMount() {
const newData = getDataWithinRange(this.props.dateRange)
this.setState({data: newData})
this.setState({dimensions: getDimensions()})
this.setState({xScale: getXScale()})
this.setState({yScale: getYScale()})
}
componentDidUpdate(prevProps, prevState) {
if (prevProps.dateRange != this.props.dateRange) {
const newData = getDataWithinRange(this.props.dateRange)
this.setState({data: newData})
}
if (prevProps.margins != this.props.margins) {
this.setState({dimensions: getDimensions()})
}
if (
prevState.data != this.state.data
|| prevState.dimensions != this.state.dimensions
) {
this.setState({xScale: getXScale()})
this.setState({yScale: getYScale()})
}
}
render() {
return (
<svg className="Chart" />
)
}
}
Function component

Our hooks' dependency arrays don't care whether our margins or dimensions are in our props, state, or neither - a value is a value, as far as they're concerned.

const Chart = ({ dateRange, margins }) => {
const data = useMemo(() => (
getDataWithinRange(dateRange)
), [dateRange])
const dimensions = useMemo(getDimensions, [margins])
const xScale = useMemo(getXScale, [data, dimensions])
const yScale = useMemo(getYScale, [data, dimensions])
return (
<svg className="Chart" />
)
}

Keeping code concise

Shorter code isn't necessarily easier to read, but keeping our components as clutter-free as possible is definitely a win!

Let's quickly compare the length of the examples we've just read:

20 lines
15 lines
23 lines
12 lines
23 lines
9 lines
36 lines
12 lines
39 lines
12 lines

Hey! Our function components are, on average, 46.1% of the length of their class component counterparts.

Additionally, let's highlight each of the code changes across the two versions:

20 lines
15 lines
23 lines
12 lines
23 lines
9 lines
36 lines
12 lines
39 lines
12 lines

We can see how we're keeping each concern separate from its friends - grouping by concern instead of by lifecycle event.

Sharing more complex functionality

We've always had the ability to share utility functions between components, but they never had access to the components' lifecycle events. Sure, we had tricks like Higher-Order Components, but they often felt like more trouble than they were worth, and cluttered up our render functions.

In every project I'm currently working on, I have a library of simple hooks that just make my life easier. In my experience, the main benefits of being able to share complex code are:

  • not having to re-write common patterns like keeping svg dimensions in-sync with a wrapper element. This one especially was lots of code that was a pain to replicate for every chart
  • KISS - letting me think through each pattern one time, ensuring that I didn't miss anything like cleaning up an event listener

Okay, Amelia, I get it. Let's get to the meat of the matter - here is a collection of hooks that I've been really appreciating these days.

Custom hooks that I ♥

useIsMounted
If I'm using any async code, I make sure to check if my component is still mounted before doing anything like updating its state.
show me the code
useIsMounted
1.
export const useIsMounted = () => {
2.
const isMounted = useRef(false)
3.
useEffect(() => {
4.
isMounted.current = true
5.
return () => isMounted.current = false
6.
}, [])
7.
return isMounted
8.
}
useIsInView
For triggering animations that start when a user scrolls to an element. I used this a lot for the parse.ly marketing site.
show me the code
useIsInView
1.
const useIsInView = (margin="0px") => {
2.
const [isIntersecting, setIntersecting] = useState(false)
3.
const ref = useRef()
4.
5.
useEffect(() => {
6.
const observer = new IntersectionObserver(([ entry ]) => {
7.
setIntersecting(entry.isIntersecting)
8.
}, { rootMargin: margin })
9.
if (ref.current) observer.observe(ref.current)
10.
return () => {
11.
observer.unobserve(ref.current)
12.
}
13.
}, [])
14.
15.
return [ref, isIntersecting]
16.
}
useHash
For keeping the hash of the url in-sync with a local variable. This is helpful for storing, for example, a filtered view of a chart in the url, so that a visitor can share that specific view.
show me the code
useHash
1.
const useHash = (initialValue=null) => {
2.
const [storedValue, setStoredValue] = useState(() => {
3.
try {
4.
const item = window.location.hash
5.
return item ? item.slice(1) : initialValue
6.
} catch (error) {
7.
console.log(error)
8.
return initialValue
9.
}
10.
})
11.
12.
const setValue = value => {
13.
try {
14.
setStoredValue(value)
15.
history.pushState(null, null, `#${value}`)
16.
} catch (error) {
17.
console.log(error)
18.
}
19.
}
20.
21.
return [storedValue, setValue]
22.
}
useOnKeyPress
For triggering code when the user presses a specific key. The isDebugging variable is optional, but I find it helpful for figuring out the exact value for a key that I need to listen for.
show me the code
useOnKeyPress
1.
const useOnKeyPress = (targetKey, onKeyDown, onKeyUp, isDebugging=false) => {
2.
const [isKeyDown, setIsKeyDown] = useState(false)
3.
4.
const onKeyDownLocal = useCallback(e => {
5.
if (isDebugging) console.log("key down", e.key, e.key != targetKey ? "- isn't triggered" : "- is triggered")
6.
if (e.key != targetKey) return
7.
setIsKeyDown(true)
8.
9.
if (typeof onKeyDown != "function") return
10.
onKeyDown(e)
11.
})
12.
const onKeyUpLocal = useCallback(e => {
13.
if (isDebugging) console.log("key up", e.key, e.key != targetKey ? "- isn't triggered" : "- is triggered")
14.
if (e.key != targetKey) return
15.
setIsKeyDown(false)
16.
17.
if (typeof onKeyUp != "function") return
18.
onKeyUp(e)
19.
})
20.
21.
useEffect(() => {
22.
addEventListener('keydown', onKeyDownLocal)
23.
addEventListener('keyup', onKeyUpLocal)
24.
25.
return () => {
26.
removeEventListener('keydown', onKeyDownLocal)
27.
removeEventListener('keyup', onKeyUpLocal)
28.
}
29.
}, [])
30.
31.
return isKeyDown
32.
}
useChartDimensions
This one is especially helpful! The way <svg> elements scale can be tricky, as well as maintaining consistent margin widths for a chart, so this helps me keep charts responsive, and automatically updates any dimensions when the window is resized.
show me the code
useChartDimensions
1.
const combineChartDimensions = dimensions => {
2.
let parsedDimensions = {
3.
marginTop: 40,
4.
marginRight: 30,
5.
marginBottom: 40,
6.
marginLeft: 75,
7.
...dimensions,
8.
}
9.
10.
return {
11.
...parsedDimensions,
12.
boundedHeight: Math.max(parsedDimensions.height - parsedDimensions.marginTop - parsedDimensions.marginBottom, 0),
13.
boundedWidth: Math.max(parsedDimensions.width - parsedDimensions.marginLeft - parsedDimensions.marginRight, 0),
14.
}
15.
}
16.
17.
export const useChartDimensions = passedSettings => {
18.
const ref = useRef()
19.
const dimensions = combineChartDimensions(passedSettings)
20.
21.
const [width, changeWidth] = useState(0)
22.
const [height, changeHeight] = useState(0)
23.
24.
useEffect(() => {
25.
if (dimensions.width && dimensions.height) return
26.
27.
const element = ref.current
28.
const resizeObserver = new ResizeObserver(entries => {
29.
if (!Array.isArray(entries)) return
30.
if (!entries.length) return
31.
32.
const entry = entries[0]
33.
34.
if (width != entry.contentRect.width) changeWidth(entry.contentRect.width)
35.
if (height != entry.contentRect.height) changeHeight(entry.contentRect.height)
36.
})
37.
38.
resizeObserver.observe(element)
39.
40.
return () => resizeObserver.unobserve(element)
41.
}, [])
42.
43.
const newSettings = combineChartDimensions({
44.
...dimensions,
45.
width: dimensions.width || width,
46.
height: dimensions.height || height,
47.
})
48.
49.
return [ref, newSettings]
50.
}
useCookie
This seriously make getting & setting cookies a breeze. The only issue, at the moment, is that the hook value doesn't update when you set it -- but this should be updated in the future.
useInterval
Especially useful for animations, or anything that needs to loop. Dan Abramov's blog post on this (linked) is especially great for understanding some gotchas with creating custom hooks.
useLocalStorage
For keeping a localStorage value in-sync with a local variable.

More learnings about React Hooks