The Iceberg of React Hooks - Sandro Dolidze

By Sandro Dolidze

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.

🔗 Hooks API Reference: useRef

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.

🔗 React Hooks: Memoization

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> );

}

🔗 Hooks API Reference: Using a Custom Hook