logo
Back to blogs

Mastering Custom Hooks in React

Mar 10, 2023 - 15 min read

Mastering Custom Hooks in React

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:

useCount.ts
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.

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:

Counter.tsx
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.

Solving with useCallback

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

Counter.tsx
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

Understanding 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:

memoization.ts
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.

Referential Equality and Objects

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

Generic Memoization

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.

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 a callback function.

useCallback vs. useMemo

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

Counter.tsx
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.

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.