← Back to home

Mastering Custom Hooks in React: Unlocking Reusability and Clean Code

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.

What Are Custom Hooks?

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.

A Simple Example

Consider a basic counter implementation using a custom hook:

import { useState } from "react";
function useCount() {
  const [count, setCount] = useState(0);
  const increment = () => setCount((c) => c + 1);
  return { count, increment };
}
function Counter() {
  const { count, increment } = useCount();
  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.

The Power of Abstraction

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.

Managing Function Identity with useCallback

Imagine modifying the earlier Counter component to increment the count every second using useEffect:

import { useEffect } from "react";
function Counter() {
  const { count, increment } = useCount();

  useEffect(() => {
    const id = setInterval(() => {
      increment();
    }, 1000);
    return () => clearInterval(id);
  }, [increment]); // Dependency array
  return <div>{count}</div>;
}

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.

Solving with useCallback

To stabilize the increment function's identity, wrap it with useCallback:

import { useState, useCallback, useEffect } from "react";
function useCount() {
  const [count, setCount] = useState(0);
  const increment = useCallback(() => setCount((c) => c + 1), []);
  return { count, increment };
}
function Counter() {
  const { count, increment } = useCount();
  useEffect(() => {
    const id = setInterval(increment, 1000);
    return () => clearInterval(id);
  }, [increment]);
  return <div>{count}</div>;
}

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.

Understanding Memoization

What is Memoization?

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:

const values = {};
function addOne(num: number) {
  if (values[num] === undefined) {
    values[num] = num + 1; // Computation
  }
  return values[num];
}

Here, addOne caches the result of adding one to a number, preventing recalculation for the same input.

Referential Equality and Objects

Consider object instantiation:

class Dog {
  constructor(public name: string) {}
}
const dog1 = new Dog('sam');
const dog2 = new Dog('sam');
console.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:\

const dogs = {};
function getDog(name: string) {
  if (dogs[name] === undefined) {
    dogs[name] = new Dog(name);
  }
  return dogs[name];
}
const dog1 = getDog("sam");
const dog2 = getDog("sam");
console.log(dog1 === dog2); // true

Generic Memoization

You can abstract memoization for reusable functionality:

function memoize<ArgType, ReturnValue>(cb: (arg: ArgType) => ReturnValue) {
  const cache: Record<string, ReturnValue> = {};
  return function memoized(arg: ArgType) {
    if (cache[arg] === undefined) {
      cache[arg] = cb(arg);
    }
    return cache[arg];
  };
}
const addOne = memoize((num: number) => num + 1);
const 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.

Memoization in React with useMemo and useCallback

React provides two hooks—useMemo and useCallback—to handle memoization:\

  • useMemo: Memoizes the result of a function.
  • useCallback: Memoizes the function itself.

useCallback vs. useMemo

While both hooks serve similar purposes, useCallback is essentially a specialized version of useMemo for functions

// useMemo version
const increment = useMemo(() => () => setCount((c) => c + 1), []);

// useCallback version
const 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.

Best Practices and Pitfalls

  • Avoid Overusing Memoization: Not every function needs to be memoized. Overusing useCallback or useMemo can add unnecessary complexity and even degrade performance.
  • Stable Dependencies: Ensure that dependencies are stable. Using objects or functions as dependencies can lead to frequent updates if they aren't memoized themselves.
  • Understand When to Abstract: While custom hooks promote reusability, abstracting too early can complicate dependency management. Focus on creating abstractions when they provide clear benefits.