Featured image of post Designing Clean React Custom Hooks for Reusability Featured image of post Designing Clean React Custom Hooks for Reusability

Designing Clean React Custom Hooks for Reusability

Learn how to separate business logic and state management from view layers using React Custom Hooks (useXXX) to improve code maintainability.

Introduction

As you develop React components, you may find that files grow to hundreds of lines. This happens when useState, useEffect, API queries, and validation logic mix with rendering markup, making the component hard to read and maintain.

This mixing of concerns makes writing unit tests difficult and increases the risk of copy-paste bugs when reusing logic across different views.

The best way to separate UI (presentation) from business logic (behavior) in React is to create Custom Hooks. This guide outlines the core design guidelines for building reusable custom hooks.


1. What are Custom Hooks?

A custom hook is a standard JavaScript function whose name starts with the prefix use. Within a custom hook, you can call standard React hooks like useState, useEffect, and useContext.

Advantages of Custom Hooks

  • Clean Components (Separation of Concerns): Components focus strictly on declaring the visual UI structure (JSX), while data fetching, state updates, and side effects are encapsulated inside the hook.
  • Logic Reusability: Share helper logic (like window size observers, forms management, or fetch request wrappers) across multiple sibling components without duplicating code.

2. Refactoring Example: Extracting custom hooks

Let’s look at an example: a component that fetches a list of users from an API and displays it on the screen.

Before Refactoring (UI Mixed with Logic)

import { useState, useEffect } from 'react';

export function UserList() {
  const [users, setUsers] = useState([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch('https://api.example.com/users')
      .then(res => res.json())
      .then(data => {
        setUsers(data);
        setIsLoading(false);
      })
      .catch(err => {
        setError(err);
        setIsLoading(false);
      });
  }, []);

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error occurred.</p>;

  return (
    <ul>
      {users.map(user => <li key={user.id}>{user.name}</li>)}
    </ul>
  );
}
  • The Problem: The component handles the API URL, request logic, error tracking, loading indicators, and JSX rendering. This makes it hard to test the UI and the data-fetching logic independently.

After Refactoring (Extracting useUsers)

First, isolate the data-fetching logic into a separate file as a custom hook named useUsers:

// useUsers.js
import { useState, useEffect } from 'react';

export function useUsers() {
  const [users, setUsers] = useState([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let active = true;
    fetch('https://api.example.com/users')
      .then(res => res.json())
      .then(data => {
        if (active) {
          setUsers(data);
          setIsLoading(false);
        }
      })
      .catch(err => {
        if (active) {
          setError(err);
          setIsLoading(false);
        }
      });
    
    // Clean up to prevent race conditions
    return () => { active = false; };
  }, []);

  // Return only the data and states required by components
  return { users, isLoading, error };
}

Now, update the component to use the custom hook:

// UserList.jsx
import { useUsers } from './useUsers';

export function UserList() {
  // Access data and state via the custom hook
  const { users, isLoading, error } = useUsers();

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error occurred.</p>;

  return (
    <ul>
      {users.map(user => <li key={user.id}>{user.name}</li>)}
    </ul>
  );
}
  • The Result: The component is now thin and readable. It does not need to know how the API request is configured, and you can write isolated tests for both the UI component and the custom hook.

3. Three Rules for Designing Custom Hooks

Rule 1: Do Not Return JSX (HTML Markup)

Custom hooks should manage logical state, not return visual components. Return objects, arrays, variables, or callback functions instead of JSX elements (like <div />) to keep the hook flexible.

Rule 2: Keep the Return Interface Small

Only return variables that are required by the component to manage its rendering lifecycle. Avoid returning every internal state variable declared inside the hook.

Rule 3: Nest and Compose Hooks

You can call other custom hooks within your hooks. For example, you can call a generic useFetch hook inside useUsers to further modularize your code.

Conclusion

React custom hooks help keep your components readable and testable:

  1. Extract data-fetching and state management logic into use-prefixed functions.
  2. Encapsulate side effects and fetch states, returning only the variables the UI component needs.
  3. Handle cleanup logic inside the hook to prevent memory leaks and race conditions.

Adopt these custom hook design patterns to build more maintainable React applications.