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:
- Extract data-fetching and state management logic into
use-prefixed functions. - Encapsulate side effects and fetch states, returning only the variables the UI component needs.
- 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.
