Mar 10, 2023 - 15 min read
If you're diving deep into React, you've likely encountered custom hooks. These powerful tools enable developers to encapsulate complex logic and promote reusability across components. But what exactly are custom hooks, and how can you harness their full potential without falling into common pitfalls? Let’s explore.
At their core, custom hooks are JavaScript functions that utilize React’s built-in hooks. They follow a naming convention by starting with the use prefix, signaling that they adhere to the Rules of Hooks. This convention is not just stylistic—it ensures consistency and predictability in how hooks behave within your components.
Consider a basic counter implementation using a custom hook:
1import { useState } from "react";
2function useCount() {
3 const [count, setCount] = useState(0);
4 const increment = () => setCount((c) => c + 1);
5 return { count, increment };
6}
7function Counter() {
8 const { count, increment } = useCount();
9 return <button onClick={increment}>{count}</button>;
Here, useCount is a custom hook that manages a count state and provides an increment function. The Counter component utilizes this hook, keeping its logic clean and focused on rendering.
Custom hooks shine by allowing you to encapsulate complex logic and share functionality across multiple components. This not only promotes DRY (Don't Repeat Yourself) principles but also makes your codebase more maintainable and scalable. However, abstraction isn't free. When you abstract logic into custom hooks, especially functions that are dependencies in other hooks like useEffect, you need to be mindful of identity and referential equality. Let's delve into a common scenario that highlights this.
Imagine modifying the earlier Counter component to increment the count every second using useEffect:
1import { useEffect } from "react";
2function Counter() {
3 const { count, increment } = useCount();
4
5 useEffect(() => {
6 const id = setInterval(() => {
7 increment();
8 }, 1000);
9 return () => clearInterval(id);
10 }, [increment]); // Dependency array
11 return <div>{count}</div>;
12}
The useEffect hook depends on the increment function. However, as currently defined, increment is a new function on every render. This causes the useEffect cleanup to run and set up a new interval each time, leading to potential performance issues and unexpected behavior.
To stabilize the increment function's identity, wrap it with useCallback:
1import { useState, useCallback, useEffect } from "react";
2function useCount() {
3 const [count, setCount] = useState(0);
4 const increment = useCallback(() => setCount((c) => c + 1), []);
5 return { count, increment };
6}
7function Counter() {
8 const { count, increment } = useCount();
9 useEffect(() => {
10 const id = setInterval(increment, 1000);
11 return () => clearInterval(id);
12 }, [increment]);
13 return <div>{count}</div>;
14}
By wrapping increment with useCallback and providing an empty dependency array, we ensure that increment maintains the same reference across renders unless its dependencies change. This prevents unnecessary cleanup and re-initialization of the interval
Memoization is a performance optimization technique that caches the results of expensive function calls and returns the cached result when the same inputs occur again. It's a form of caching that avoids redundant computations.
A simple memoization example:
1const values = {};
2function addOne(num: number) {
3 if (values[num] === undefined) {
4 values[num] = num + 1; // Computation
5 }
6 return values[num];
7}
Here, addOne caches the result of adding one to a number, preventing recalculation for the same input.
Consider object instantiation:
1class Dog {
2 constructor(public name: string) {}
3}
4const dog1 = new Dog('sam');
5const dog2 = new Dog('sam');
6console.log(dog1 === dog2); // false
Even though dog1 and dog2 have the same properties, they are distinct instances. Memoization can help in scenarios where you need consistent references:
1const dogs = {};
2function getDog(name: string) {
3 if (dogs[name] === undefined) {
4 dogs[name] = new Dog(name);
5 }
6 return dogs[name];
7}
8const dog1 = getDog("sam");
9const dog2 = getDog("sam");
10console.log(dog1 === dog2); // true
You can abstract memoization for reusable functionality:
1function memoize<ArgType, ReturnValue>(cb: (arg: ArgType) => ReturnValue) {
2 const cache: Record<string, ReturnValue> = {};
3 return function memoized(arg: ArgType) {
4 if (cache[arg] === undefined) {
5 cache[arg] = cb(arg);
6 }
7 return cache[arg];
8 };
9}
10const addOne = memoize((num: number) => num + 1);
11const getDog = memoize((name: string) => new Dog(name));
Note: This basic implementation assumes that ArgType can be used as a key in the cache object, which may not hold for all types.
While both hooks serve similar purposes, useCallback is essentially a specialized version of useMemo for functions
1// useMemo version
2const increment = useMemo(() => () => setCount((c) => c + 1), []);
3
4// useCallback version
5const increment = useCallback(() => setCount((c) => c + 1), []);
Both achieve the same outcome: a stable increment function reference that doesn't change across renders unless dependencies do.