The Iceberg of React Hooks
React Hooks
, unlike Class Components
, provide low-level building blocks for optimizing and composing applications with minimal boilerplate.
Without in-depth knowledge, performance problems can arise and code complexity can increase due to subtle bugs and leaky abstractions.
I’ve created 12 part case study to demonstrate common problems and ways to fix them. I’ve also compiled React Hooks Radar
and React Hooks Checklist
for small recommendations and quick reference.
Case Study: Implementing Interval
The goal is to implement counter that starts from 0
and increases every 500ms
. Three control buttons should be provided: start
, stop
and clear
.
Level 0: Hello World
export default function Level00() {
console.log('renderLevel00'); const [count, setCount] = useState(0); return (
<div>
count => {count}
<button onClick={() => setCount(count + 1)}>+</button>
<button onClick={() => setCount(count - 1)}>-</button>
</div>
);
}
This is a simple, correctly implemented counter, which increments or decrements on user click.
Level 1: setInterval
export default function Level01() {
console.log('renderLevel01'); const [count, setCount] = useState(0); setInterval(() => {
setCount(count + 1);
}, 500); return <div>count => {count}</div>;
}
Intention of this code is to increase counter every 500ms
. This code has a huge resource leak and is implemented incorrectly. It will easily crash browser tab. Since Level01
function is called every time render happens, this component creates new interval every time render is triggered.
Mutations, subscriptions, timers, logging, and other side effects are not allowed inside the main body of a function component (referred to as React’s render phase). Doing so will lead to confusing bugs and inconsistencies in the UI.
🔗 Hooks API Reference: useEffect
Level 2: useEffect
export default function Level02() {
console.log('renderLevel02'); const [count, setCount] = useState(0); useEffect(() => {
setInterval(() => {
setCount(count + 1);
}, 500);
}); return <div>Level 2: count => {count}</div>;
}
Most side-effects happen inside useEffect
. This code also has a huge resource leak and is implemented incorrectly. Default behavior of useEffect
is to run after every render, so new interval will be created every time count changes.
🔗 Hooks API Reference: useEffect, Timing of Effects.
Level 3: run only once
export default function Level03() {
console.log('renderLevel03'); const [count, setCount] = useState(0); useEffect(() => {
setInterval(() => {
setCount(count + 1);
}, 300);
}, []); return <div>count => {count}</div>;
}
Giving []
as second argument to useEffect
will call function once, after mount
. Even though setInterval
is called only once, this code is implemented incorrectly.
count
will increase from 0
to 1
and stay that way. Arrow function will be created once and when that happens, count
will be 0
.
This code has subtle resource leak. Even after component unmounts, setCount
will still be called.
🔗 Hooks API Reference: useEffect, Conditionally firing an effect.
Level 4: cleanup
useEffect(() => {
const interval = setInterval(() => {
setCount(count + 1);
}, 300);
return () => clearInterval(interval);
}, []);
To prevent resource leaks, everything must be disposed when lifecycle of a hook ends. In this case returned function will be called after component unmounts.
This code does not have resource leaks, but is implemented incorrectly, just like previous one.
🔗 Hooks API Reference: Cleaning up an effect.
Level 5: use count
as dependency
useEffect(() => {
const interval = setInterval(() => {
setCount(count + 1);
}, 500);
return () => clearInterval(interval);
}, [count]);
Giving array of dependencies
to useEffect
will change its lifecycle. In this example useEffect
will be called once after mount
and every time count
changes. Cleanup function will be called every time count
changes to dispose previous resource.
This code works correctly, without any bugs, but it’s slightly misleading. setInterval
is created and disposed every 500ms
. Each setInterval
is always called once.
🔗 Hooks API Reference: useEffect, Conditionally firing an effect.
Level 6: setTimeout
useEffect(() => {
const timeout = setTimeout(() => {
setCount(count + 1);
}, 500);
return () => clearTimeout(timeout);
}, [count]);
This code and the code above work correctly. Since useEffect
is called every time count
changes, using setTimeout
has same effect as calling setInterval
.
This example is inefficient, new setTimeout
is created every time render happens. React
has a better way for fixing the problem.
Level 7: functional updates for useState
useEffect(() => {
const interval = setInterval(() => {
setCount(c => c + 1);
}, 500);
return () => clearInterval(interval);
}, []);
In previous example we ran useEffect
on each count
change. The was necessary because we needed to have always up-to-date current value.
useState
provides API to update previous state without capturing the current value. To do that, all we need to do is provide lambda to setState
.
This code works correctly and more efficiently. We are using a single setInterval
during the lifecycle of a component. clearInterval
will only be called once after component is unmounted.
🔗 Hooks API Reference: useState, Functional updates.
Level 8: local variable
export default function Level08() {
console.log('renderLevel08'); const [count, setCount] = useState(0); let interval = null; const start = () => {
interval = setInterval(() => {
setCount(c => c + 1);
}, 500);
}; const stop = () => {
clearInterval(interval);
}; return (
<div>
count => {count}
<button onClick={start}>start</button>
<button onClick={stop}>stop</button>
</div>
);
}
We’ve added start
and stop
buttons. This code is implemented incorrectly, stop
button does not work. New reference is created during each render
, so stop
will have reference to null
.
🔗 Hooks API Reference: Is there something like instance variables?
Level 9: useRef
export default function Level09() {
console.log('renderLevel09'); const [count, setCount] = useState(0); const intervalRef = useRef(null); const start = () => {
intervalRef.current = setInterval(() => {
setCount(c => c + 1);
}, 500);
}; const stop = () => {
clearInterval(intervalRef.current);
}; return (
<div>
count => {count}
<button onClick={start}>start</button>
<button onClick={stop}>stop</button>
</div>
);
}
useRef
is the go-to hook if mutable variable is needed. Unlike local variables, React
makes sure same reference is returned during each render.
This code seems correct, but has a subtle bug. If start
is called multiple times, setInterval
will be called multiple times triggering resource leak.
Level 10: useCallback
export default function Level10() {
console.log('renderLevel10'); const [count, setCount] = useState(0); const intervalRef = useRef(null); const start = () => {
if (intervalRef.current !== null) {
return;
} intervalRef.current = setInterval(() => {
setCount(c => c + 1);
}, 500);
}; const stop = () => {
if (intervalRef.current === null) {
return;
} clearInterval(intervalRef.current);
intervalRef.current = null;
}; return (
<div>
count => {count}
<button onClick={start}>start</button>
<button onClick={stop}>stop</button>
</div>
);
}
To avoid resource leak, we simply ignore calls if interval
is already started. Although calling clearInterval(null)
does not trigger any errors, it’s still good practice to dispose resource only once.
This code has no resource leaks, is implemented correctly, but might have a performance problem.
memoization
is main performance optimization tool in React
. React.memo
does shallow comparison and if references are same, render is skipped.
If start
and stop
were passed to a memoized
component, the whole optimization would fail, because new reference is returned after each render.
Level 11: useCallback
export default function Level11() {
console.log('renderLevel11'); const [count, setCount] = useState(0); const intervalRef = useRef(null); const start = useCallback(() => {
if (intervalRef.current !== null) {
return;
} intervalRef.current = setInterval(() => {
setCount(c => c + 1);
}, 500);
}, []); const stop = useCallback(() => {
if (intervalRef.current === null) {
return;
} clearInterval(intervalRef.current);
intervalRef.current = null;
}, []);return (
<div>
count => {count}
<button onClick={start}>start</button>
<button onClick={stop}>stop</button>
</div>
);
}
To enable React.memo
to do its job properly, all we need to do it to memoize
functions, using useCallback
hook. This way, same reference will be provided after each render.
This code has no resource leaks, is implemented correctly, has no performance problem, but code is quite complex, even for a simple counter.
🔗 Hooks API Reference: useCallback
Level 12: custom hook
function useCounter(initialValue, ms) {
const [count, setCount] = useState(initialValue);
const intervalRef = useRef(null); const start = useCallback(() => {
if (intervalRef.current !== null) {
return;
} intervalRef.current = setInterval(() => {
setCount(c => c + 1);
}, ms);
}, []); const stop = useCallback(() => {
if (intervalRef.current === null) {
return;
} clearInterval(intervalRef.current);
intervalRef.current = null;
}, []); const reset = useCallback(() => {
setCount(0);
}, []); return { count, start, stop, reset };
}
To simplify code, we need to encapsulate all complexity inside useCounter
custom hook and expose clean api: { count, start, stop, reset }
.
export default function Level12() {
console.log('renderLevel12'); const { count, start, stop, reset } = useCounter(0, 500); return (
<div>
count => {count}
<button onClick={start}>start</button>
<button onClick={stop}>stop</button>
<button onClick={reset}>reset</button>
</div>
);
}
React Hooks Radar
All React Hooks
are equal, but some hooks are more equal than others.
✅ Green
Green hooks are main building blocks of modern React
applications. They are safe to use almost everywhere without much thinking.
🌕 Yellow
Yellow hooks provide useful performance optimizations by using memoization. Managing lifecycle and inputs should be done with caution.
🔴 Red
Red hooks interact with mutable world using side effects. They are most powerful and should be used with extreme caution. Customs hooks are recommended for all non-trivial use-cases.
React Hooks Checklist
- Obey Rules of Hooks.
- Don’t do any side-effects in main render function.
- Unsubscribe/dispose/destroy all used resources.
- Prefer
useReducer
or functional updates foruseState
to prevent reading and writing same value in a hook. - Don’t use mutable variables inside render function, use
useRef
instead. - If what you save in
useRef
has smaller lifecycle than the component itself, don’t forget to unset the value when disposing the resource. - Be cautions with infinite recursion and resource starvation.
- Memoize functions and objects when needed to improve performance.
- Correctly capture input dependencies (
undefined
=> every render,[a, b]
=> whena
orb
change,[]
=> only once). - Use customs hooks for non-trivial use-cases.