How to Create Custom Hook

Introduction React hooks revolutionized how developers manage state and side effects in functional components. With the introduction of hooks like useState, useEffect, and useContext, React enabled cleaner, more reusable code. But as applications grow in complexity, developers soon realize that built-in hooks alone aren’t enough. That’s where custom hooks come in. Custom hooks allow you to extract

Oct 25, 2025 - 13:19
Oct 25, 2025 - 13:19
 0

Introduction

React hooks revolutionized how developers manage state and side effects in functional components. With the introduction of hooks like useState, useEffect, and useContext, React enabled cleaner, more reusable code. But as applications grow in complexity, developers soon realize that built-in hooks alone arent enough. Thats where custom hooks come in.

Custom hooks allow you to extract component logic into reusable functions, promoting DRY principles and improving code organization. However, not all custom hooks are created equal. A poorly constructed hook can introduce bugs, memory leaks, performance bottlenecks, and unpredictable behaviorespecially in large-scale applications.

This article reveals the top 10 proven methods to create custom hooks you can trust. These arent just coding tipsthey are battle-tested practices used by senior React engineers at Fortune 500 companies and open-source maintainers. Whether youre building a small dashboard or a complex SaaS platform, these principles will ensure your hooks are reliable, testable, and scalable.

Trust in your code isnt accidental. Its engineered. And the foundation of that engineering starts with how you design your custom hooks.

Why Trust Matters

When you write a custom hook, youre not just abstracting logicyoure creating a contract. Other developers, including your future self, will rely on that contract to behave predictably. A hook that works in one component but breaks in another due to unhandled edge cases undermines team confidence and slows development.

Untrusted hooks lead to:

  • Hard-to-debug state inconsistencies
  • Memory leaks from uncleaned subscriptions or timers
  • Performance degradation from unnecessary re-renders
  • Testing nightmares due to hidden dependencies
  • Team friction and code reviews that become arguments

Trust is earned through predictability. A trustworthy hook:

  • Always returns the same output for the same inputs
  • Doesnt mutate external state without explicit intent
  • Handles edge cases gracefully (e.g., null, undefined, empty arrays)
  • Follows Reacts rules of hooks without exceptions
  • Is thoroughly documented and tested

Consider this: if a hook is used across 15 components and has a subtle bug, that bug multiplies in impact. Fixing it becomes a high-risk, high-cost operation. By contrast, a trusted hook is deployed once and forgottenuntil it needs to evolve.

Building trust begins with structure. The following 10 practices form the core of creating hooks that dont just workthey endure.

Top 10 How to Create Custom Hook You Can Trust

1. Always Follow Reacts Rules of Hooks

Reacts rules arent suggestionstheyre non-negotiable. Violating them leads to undefined behavior thats nearly impossible to debug. The two rules are:

  1. Only call hooks at the top level of your custom hooknever inside loops, conditions, or nested functions.
  2. Only call hooks from React functions (custom hooks or components)never from regular JavaScript functions.

These rules ensure React can maintain the correct order of hook calls between renders. If you break them, React loses track of which state corresponds to which hook, leading to stale data or crashes.

Example of a trusted pattern:

function useLocalStorage(key, initialValue) {

// ? Called at top level

const [storedValue, setStoredValue] = useState(() => {

try {

const item = window.localStorage.getItem(key);

return item ? JSON.parse(item) : initialValue;

} catch (error) {

console.error(error);

return initialValue;

}

});

const setValue = (value) => {

try {

const valueToStore = value instanceof Function ? value(storedValue) : value;

setStoredValue(valueToStore);

window.localStorage.setItem(key, JSON.stringify(valueToStore));

} catch (error) {

console.error(error);

}

};

return [storedValue, setValue];

}

Example of an untrusted pattern:

// ? DONT DO THIS

function useUserData(userId) {

if (userId) {

const [data, setData] = useState(null); // Violates Rule 1

useEffect(() => {

fetchUser(userId).then(setData);

}, [userId]);

}

}

Even if the code appears to work in development, it will fail unpredictably in production under different rendering conditions. Always structure your hooks to comply with Reacts rules. Tools like the ESLint plugin eslint-plugin-react-hooks will catch violations during development.

2. Use Dependency Arrays Correctly in useEffect

The useEffect hook is one of the most commonly misused parts of React. When creating custom hooks, you often wrap side effects like API calls, subscriptions, or timers. The dependency array determines when those effects runand failing to manage it correctly is a leading cause of bugs.

Three principles govern trustworthy dependency arrays:

  1. Include every value used inside the effect that comes from props, state, or context.
  2. Dont include values that never change (like constants or functions memoized with useCallback).
  3. When in doubt, include itthen optimize with useCallback or useMemo if performance suffers.

Example of a trusted pattern:

function useFetch(url) {

const [data, setData] = useState(null);

const [loading, setLoading] = useState(true);

const [error, setError] = useState(null);

useEffect(() => {

const fetchData = async () => {

try {

setLoading(true);

const response = await fetch(url);

if (!response.ok) throw new Error('Network response was not ok');

const result = await response.json();

setData(result);

} catch (err) {

setError(err.message);

} finally {

setLoading(false);

}

};

fetchData();

}, [url]); // ? url is the only dependency

return { data, loading, error };

}

What if you use a function inside the effect?

function useDelayedAction(callback, delay) {

useEffect(() => {

const timer = setTimeout(callback, delay);

return () => clearTimeout(timer);

}, [callback, delay]); // ? Both are dependencies

}

But if you pass a new function every render without memoizing it, the effect runs unnecessarily:

// ? DONT DO THIS

function MyComponent() {

const handleClick = () => console.log('clicked');

useDelayedAction(handleClick, 1000); // New function on every render ? effect runs every time

}

Fix it with useCallback:

function MyComponent() {

const handleClick = useCallback(() => console.log('clicked'), []);

useDelayedAction(handleClick, 1000); // ? Stable reference

}

Trusting your useEffect means understanding that dependencies arent optionaltheyre the contract between your hook and Reacts reconciliation engine.

3. Return Stable References with useCallback and useMemo

React components re-render when their props or state change. If your custom hook returns a new object or function on every render, it causes unnecessary re-renders in child componentseven if the logic hasnt changed.

Use useCallback to memoize functions and useMemo to memoize values. This is critical for performance and predictability.

Example of a trusted pattern:

function useCounter(initialValue = 0) {

const [count, setCount] = useState(initialValue);

const increment = useCallback(() => {

setCount(c => c + 1);

}, []);

const decrement = useCallback(() => {

setCount(c => c - 1);

}, []);

const reset = useCallback(() => {

setCount(initialValue);

}, [initialValue]);

const double = useMemo(() => count * 2, [count]);

return { count, increment, decrement, reset, double };

}

Here, increment, decrement, and reset are stable across renders. The double value is only recalculated when count changes. This ensures that components using this hook wont re-render unless necessary.

Without useCallback and useMemo:

// ? DONT DO THIS

function useCounter(initialValue = 0) {

const [count, setCount] = useState(initialValue);

const increment = () => setCount(c => c + 1); // New function every render

const decrement = () => setCount(c => c - 1); // New function every render

const double = count * 2; // Recalculated every render, but okay for primitives

return { count, increment, decrement, double };

}

If this hook is passed to a child component wrapped in React.memo, that child will re-render on every parent rendereven though nothing meaningful changed. That defeats the purpose of memoization entirely.

Trustworthy hooks optimize for performance by default. They dont assume the consumer will memoize their own referencesthey provide them.

4. Handle Edge Cases and Invalid Inputs Gracefully

Real-world data is messy. Users input empty strings. APIs return null. Arrays are undefined. A hook that crashes on bad input isnt just unreliableits dangerous.

Always validate inputs at the entry point of your hook. Dont assume the caller will sanitize data.

Example of a trusted pattern:

function useDebounce(value, delay) {

// ? Handle null/undefined gracefully

if (value === null || value === undefined) {

return null;

}

// ? Handle invalid delay

if (typeof delay !== 'number' || delay

throw new Error('Delay must be a non-negative number');

}

const [debouncedValue, setDebouncedValue] = useState(value);

useEffect(() => {

const handler = setTimeout(() => {

setDebouncedValue(value);

}, delay);

return () => {

clearTimeout(handler);

};

}, [value, delay]);

return debouncedValue;

}

Compare this to a naive version:

// ? DONT DO THIS

function useDebounce(value, delay) {

const [debouncedValue, setDebouncedValue] = useState(value);

useEffect(() => {

const handler = setTimeout(() => {

setDebouncedValue(value); // Crashes if value is undefined

}, delay);

return () => clearTimeout(handler);

}, [value, delay]);

return debouncedValue;

}

If a developer passes undefined as value, the hook silently fails. Later, a component using it might render undefined as a string, causing layout shifts or errors in child components.

Trustworthy hooks fail early and loudly when inputs are invalid. They provide clear error messages and predictable fallbacks. When in doubt, return a default value or throw a descriptive errordont let bugs propagate silently.

5. Clean Up Side Effects Properly

Every useEffect that creates a subscription, timer, event listener, or connection must return a cleanup function. Failure to do so causes memory leaks and unintended behavior across component lifecycles.

Example of a trusted pattern:

function useWindowResize() {

const [windowSize, setWindowSize] = useState({

width: window.innerWidth,

height: window.innerHeight,

});

useEffect(() => {

const handleResize = () => {

setWindowSize({

width: window.innerWidth,

height: window.innerHeight,

});

};

window.addEventListener('resize', handleResize);

// ? Cleanup function

return () => {

window.removeEventListener('resize', handleResize);

};

}, []);

return windowSize;

}

Without the cleanup, every time the component mounts, a new event listener is addedbut none are removed. After a few navigations, dozens of listeners may be active, each firing on resize and causing performance degradation.

Another common example: WebSocket connections.

function useWebSocket(url) {

const [message, setMessage] = useState(null);

const [connected, setConnected] = useState(false);

useEffect(() => {

const socket = new WebSocket(url);

socket.onopen = () => setConnected(true);

socket.onmessage = (event) => setMessage(event.data);

socket.onclose = () => setConnected(false);

// ? Cleanup function

return () => {

socket.close();

};

}, [url]);

return { message, connected };

}

Trustworthy hooks are responsible for their own cleanup. They dont rely on the consumer to remember to unsubscribe. If a hook creates a resource, it owns the responsibility to destroy it.

6. Avoid Direct DOM Manipulation

React is declarative. You describe what the UI should look like, and React handles the DOM updates. Custom hooks that manipulate the DOM directly (e.g., using document.getElementById, innerHTML, or querySelector) break this contract and create unpredictable behavior.

Instead, use refs to access DOM elements when necessary.

Example of a trusted pattern:

function useFocus() {

const ref = useRef(null);

const focus = () => {

if (ref.current) {

ref.current.focus();

}

};

return [ref, focus];

}

Usage:

function MyInput() {

const [inputRef, focusInput] = useFocus();

return (

>

);

}

Why is this better?

  • Its compatible with Reacts reconciliation system
  • It works with server-side rendering
  • It doesnt break if the DOM structure changes
  • Its testable

Example of an untrusted pattern:

// ? DONT DO THIS

function useFocus() {

const focus = () => {

const input = document.getElementById('my-input');

if (input) input.focus();

};

return focus;

}

This hook assumes a specific ID exists. If the ID changes, or if the component is rendered multiple times, it breaks. It also fails in server-side rendered environments where document is undefined.

Trustworthy hooks respect Reacts model. They use refs to interact with the DOM, not global selectors. This makes them reusable, portable, and safe.

7. Document Your Hooks Behavior and Dependencies

A custom hook without documentation is a liability. Even the most well-written code becomes untrustworthy if others dont understand how to use it.

Every custom hook should include:

  • A clear name that reflects its purpose (e.g., useAuth, useLocalStorage, not useMyThing)
  • JSDoc-style comments explaining inputs, outputs, and side effects
  • Examples of usage
  • Known limitations or edge cases

Example of a trusted documentation pattern:

/**

* Custom hook to manage localStorage state with automatic serialization.

*

* @param {string} key - The localStorage key to store/retrieve data under

* @param {*} initialValue - The default value if no item exists in localStorage

* @returns {[*, function]} - A tuple of [storedValue, setValue]

*

* @example

* const [name, setName] = useLocalStorage('username', 'Guest');

* setName('Alice'); // Automatically saved to localStorage

*

* @note - Values are serialized to JSON. Use only serializable data (objects, arrays, strings, numbers, booleans, null).

* @note - Throws an error if localStorage is unavailable (e.g., in private browsing mode).

*/

function useLocalStorage(key, initialValue) {

// ... implementation

}

Good documentation prevents:

  • Developers passing non-serializable data (like functions or dates) and wondering why it breaks
  • Teams misusing the hook because they assume it works differently than it does
  • Debugging sessions wasted on I thought this hook did X

Trustworthy hooks are self-explanatory. Their names, signatures, and comments form a contract thats clear to any developerregardless of seniority.

8. Write Unit Tests for Your Hooks

Code without tests is code you cant trust. Custom hooks encapsulate logic thats often reused across your app. If a hook breaks, it breaks many things.

Use the @testing-library/react-hooks library to test hooks in isolation.

Example of a trusted test pattern:

import { renderHook, act } from '@testing-library/react-hooks';

import { useCounter } from './useCounter';

test('useCounter increments correctly', () => {

const { result } = renderHook(() => useCounter(0));

act(() => {

result.current.increment();

});

expect(result.current.count).toBe(1);

act(() => {

result.current.increment();

});

expect(result.current.count).toBe(2);

});

test('useCounter resets to initial value', () => {

const { result } = renderHook(() => useCounter(10));

act(() => {

result.current.increment();

result.current.reset();

});

expect(result.current.count).toBe(10);

});

These tests verify:

  • State updates correctly
  • Functions behave as expected
  • Edge cases (like initial value) are handled

Without tests, you cant prove your hook works. You can only hope it does.

Trustworthy hooks are tested. They have:

  • 100% coverage for core logic
  • Tests for error conditions
  • Tests for async behavior (if applicable)
  • Tests for re-renders with new dependencies

When you deploy a hook with tests, youre not just shipping codeyoure shipping confidence.

9. Avoid State Mutation and Side Effects in Render

React components and hooks should be pure functions during render. That means: no setting state, no logging, no fetching data, no modifying objects or arrays.

Example of a trusted pattern:

function useFilteredItems(items, filter) {

// ? Pure computation during render

const filtered = items.filter(item =>

item.name.toLowerCase().includes(filter.toLowerCase())

);

return filtered;

}

Example of an untrusted pattern:

// ? DONT DO THIS

function useFilteredItems(items, filter) {

// ? Mutating state during render

const [filtered, setFiltered] = useState([]);

// ? Side effect during render

items.forEach(item => {

if (item.name.includes(filter)) {

filtered.push(item); // This is illegal and breaks React

}

});

setFiltered(filtered); // Also illegalstate update during render

return filtered;

}

React may render a component multiple times during development (especially in StrictMode). If your hook mutates state or performs side effects during render, it will cause infinite loops, crashes, or inconsistent UI.

Always:

  • Use useState and useEffect for state changes
  • Use useMemo for expensive calculations
  • Use useCallback for function memoization
  • Never mutate state directly during render

Trustworthy hooks separate pure logic from side effects. Theyre predictable, testable, and safe.

10. Make Hooks Composable and Reusable

The real power of custom hooks lies in composition. A trustworthy hook doesnt try to do everythingit does one thing well and can be combined with others.

Example of a trusted pattern:

function useLocalStorage(key, initialValue) {

// ... returns [value, setValue]

}

function useDebounce(value, delay) {

// ... returns debouncedValue

}

function useSearch() {

const [query, setQuery] = useLocalStorage('searchQuery', '');

const debouncedQuery = useDebounce(query, 300);

return { query, setQuery, debouncedQuery };

}

Here, useSearch composes two other trusted hooks. It doesnt reimplement localStorage or debouncingit leverages them.

This approach offers:

  • Reusability: useLocalStorage can be used in 20 other hooks
  • Testability: Each small hook can be tested independently
  • Maintainability: Fixing a bug in useDebounce fixes it everywhere
  • Scalability: New features are added by composing, not rewriting

Contrast this with a monolithic hook:

// ? DONT DO THIS

function useSearch() {

const [query, setQuery] = useState('');

const [debouncedQuery, setDebouncedQuery] = useState('');

const [loading, setLoading] = useState(false);

useEffect(() => {

const timer = setTimeout(() => {

setDebouncedQuery(query);

}, 300);

return () => clearTimeout(timer);

}, [query]);

useEffect(() => {

const saved = localStorage.getItem('searchQuery');

if (saved) setQuery(saved);

}, []);

useEffect(() => {

localStorage.setItem('searchQuery', query);

}, [query]);

// ... more logic

return { query, debouncedQuery, loading };

}

This hook is hard to test, hard to reuse, and hard to debug. It violates the single-responsibility principle.

Trustworthy hooks are small, focused, and composable. They follow Unix philosophy: Do one thing and do it well.

Comparison Table

Practice Trusted Hook Example Untrusted Hook Example Consequence of Skipping
Follow Reacts Rules of Hooks useState and useEffect called at top level useState inside if-statement Stale state, crashes in production
Correct Dependency Arrays Effect re-runs only when dependencies change Missing dependencies or empty array Stale closures, infinite loops
Stable References Use useCallback and useMemo New functions/objects every render Unnecessary re-renders, memoization fails
Handle Edge Cases Check for null, undefined, invalid types Assume inputs are always valid Runtime errors, silent failures
Clean Up Side Effects Return cleanup function for timers, listeners No cleanup function Memory leaks, performance degradation
Avoid DOM Manipulation Use useRef to access elements Use document.getElementById Broken in SSR, non-portable
Document Behavior JSDoc with examples, notes, limitations No comments or examples Team confusion, misuse, debugging hell
Write Unit Tests Tested with @testing-library/react-hooks No tests Unreliable, fear of changes, regression bugs
Avoid State Mutation in Render State updated only in useEffect or event handlers Modify state directly in component body Infinite loops, React errors, unstable UI
Composable Design Small hooks combined to build complex logic Monolithic hooks doing everything Hard to maintain, reuse, or test

FAQs

Can I use a custom hook inside a class component?

No. Custom hooks can only be used inside functional components or other custom hooks. React hooks rely on the internal call stack of functional components to track state. Class components use a different lifecycle model and do not support hooks.

How do I test a custom hook that uses useEffect?

Use the @testing-library/react-hooks library. It provides a renderHook function that allows you to invoke your hook in a controlled environment. You can use act() to simulate state updates and verify side effects after rendering.

Should I always use useCallback and useMemo in my hooks?

No. Only use them when performance becomes an issue. Over-memoizing can add unnecessary complexity. Profile your app first. If child components re-render unnecessarily due to new function references, then use useCallback. If expensive calculations run too often, use useMemo.

What happens if I forget to include a dependency in useEffect?

You create a stale closure. The effect will use outdated values from previous renders, leading to bugs like stale data, incorrect API calls, or failed subscriptions. Reacts ESLint plugin will warn you about missing dependenciesalways heed those warnings.

Can custom hooks share state between components?

Not directly. Each time a custom hook is called, it creates its own isolated state. To share state between components, use React Context, a state management library (like Zustand or Redux), or lift state up to a common ancestor.

Is it okay to call multiple custom hooks in one component?

Yes. In fact, its encouraged. Reacts hook system is designed for composition. You can call as many custom hooks as needed, as long as you follow the rules of hooks (top-level, no conditions).

Do I need to wrap my custom hook in a try-catch?

It depends. If your hook interacts with external systems (localStorage, APIs, browser APIs), wrap them in try-catch to prevent crashes. But dont catch errors you cant recover fromlog them and let the app handle them gracefully.

How do I know if my hook is reusable?

Ask yourself: Can I use this hook in a different component without changing its logic? If yes, its reusable. If you find yourself copying and pasting the hook into multiple files with minor tweaks, its not abstracted enough.

Can I use async/await in a custom hook?

Yes, but only inside useEffect or other effect hooks. Never use async in the top level of a hook. Example:

function useUserData(id) {

const [user, setUser] = useState(null);

useEffect(() => {

const fetchUser = async () => {

const response = await fetch(/api/user/${id});

const data = await response.json();

setUser(data);

};

if (id) fetchUser();

}, [id]);

return user;

}

Whats the biggest mistake developers make with custom hooks?

Trying to do too much in one hook. The most trusted hooks are small, focused, and testable. Resist the urge to build the ultimate hook. Instead, build small, composable pieces and combine them.

Conclusion

Creating custom hooks you can trust isnt about writing more codeits about writing better code. Its about respecting Reacts principles, anticipating edge cases, and designing for long-term maintainability. The 10 practices outlined in this guide arent theoreticaltheyre the foundation of production-grade React applications used by teams around the world.

Trust isnt built overnight. Its built through discipline: testing every hook, documenting every behavior, cleaning up every resource, and composing every function with care. When you follow these principles, your hooks become more than utilitiesthey become reliable tools that empower your team and scale with your application.

Dont ship a hook just because it works today. Ship it because you know it will work tomorrow, next week, and in six months when someone else is maintaining it.

Build with intention. Test with rigor. Document with clarity. Compose with purpose.

Thats how you create custom hooks you can trust.