Custom Hooks 🛠️

1. Introduction 👋

Custom Hooks are one of React's most powerful patterns for code reuse. They let you extract component logic into reusable, testable functions without changing the underlying behavior. This tutorial covers everything from writing your first custom Hook to advanced patterns like combining them with Context, reducers, and TS.

Information

This tutorial assumes solid familiarity with useState, useEffect, and the other built-in Hooks covered in earlier tutorials.

2. What are Custom Hooks? 🤔

A custom Hook is simply a JavaScript function whose name starts with use, and which calls other Hooks internally to encapsulate reusable, stateful logic.

Code Snippet

function useToggle(initialValue = false) {
  const [value, setValue] = useState(initialValue);
  const toggle = () => setValue((v) => !v);
  return [value, toggle];
}

// Usage
function Modal() {
  const [isOpen, toggleOpen] = useToggle(false);
  return <button onClick={toggleOpen}>{isOpen ? "Close" : "Open"}</button>;
}
>>"Custom Hooks let you share logic between components." — React Documentation

3. Why Create Custom Hooks? 💡

  • Code Reuse: Extract logic used across multiple components into one place.
  • Readability: Hide complex implementation details behind a simple, descriptive function name.
  • Testability: Logic can be tested in isolation, separate from rendering concerns.
  • Separation of Concerns: Keep components focused on rendering, while Hooks manage behavior.

4. Rules for Custom Hooks ⚖️

Custom Hooks must follow the same Rules of Hooks as built-in Hooks, since they're built entirely out of them.

  1. Only call Hooks at the top level — never inside loops, conditions, or nested functions.
  2. Only call Hooks from React function components or other custom Hooks.
  3. The function name must start with use so React and linting tools recognize it as a Hook.

Danger

Naming a function getToggle instead of useToggle, while calling useState inside it, breaks the linter's ability to enforce the Rules of Hooks correctly.

5. Naming Custom Hooks 🏷️

  • Always prefix with use: useAuth, useFetch, useLocalStorage.
  • Choose names that describe what the Hook provides, not how it's implemented.
  • Use camelCase consistently, matching the convention of built-in Hooks.

Tip

A well-named custom Hook (e.g., useWindowSize) should make its purpose obvious at the call site, without needing to read its implementation.

6. Creating Your First Custom Hook 🎬

useCounter.js

function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);

  const increment = () => setCount((c) => c + 1);
  const decrement = () => setCount((c) => c - 1);
  const reset = () => setCount(initialValue);

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

// Usage
function Counter() {
  const { count, increment, decrement, reset } = useCounter(0);

  return (
    <div>
      <p>{count}</p>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
      <button onClick={reset}>Reset</button>
    </div>
  );
}

7. Reusing State Logic 🔄

Custom Hooks excel at extracting stateful logic that would otherwise be duplicated across several components.

Code Snippet

function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    const stored = localStorage.getItem(key);
    return stored ? JSON.parse(stored) : initialValue;
  });

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue];
}

// Reused across many components
function ThemeSwitcher() {
  const [theme, setTheme] = useLocalStorage("theme", "light");
}
function LanguageSelector() {
  const [language, setLanguage] = useLocalStorage("language", "en");
}

8. Reusing Side Effects ⚡

Code Snippet

function useDocumentTitle(title) {
  useEffect(() => {
    const previousTitle = document.title;
    document.title = title;
    return () => {
      document.title = previousTitle;
    };
  }, [title]);
}

// Usage
function ProductPage({ product }) {
  useDocumentTitle(`${product.name} | My Store`);
}

Tip

Wrapping useEffect-based logic in a custom Hook centralizes both the setup and cleanup logic, avoiding duplication across every component that needs it.

9. Combining Multiple Hooks 🧩

Code Snippet

function useAuthenticatedUser() {
  const { token } = useAuth();
  const [user, setUser] = useState(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    if (!token) return;
    fetchUser(token).then((data) => {
      setUser(data);
      setIsLoading(false);
    });
  }, [token]);

  return { user, isLoading };
}

Note

A single custom Hook can freely combine useState, useEffect, useContext, and even other custom Hooks internally.

10. Sharing Business Logic 📊

Beyond simple UI state, custom Hooks are excellent for encapsulating domain-specific logic — validation rules, calculations, or workflows unique to your application.

Code Snippet

function useShoppingCart() {
  const [items, setItems] = useState([]);

  const addItem = (item) => setItems((prev) => [...prev, item]);
  const removeItem = (id) => setItems((prev) => prev.filter((i) => i.id !== id));
  const total = items.reduce((sum, item) => sum + item.price, 0);

  return { items, addItem, removeItem, total };
}

11. Returning Values 📤

A custom Hook can return a single value, when only one piece of information is needed by the consumer.

Code Snippet

function useIsOnline() {
  const [isOnline, setIsOnline] = useState(navigator.onLine);

  useEffect(() => {
    const goOnline = () => setIsOnline(true);
    const goOffline = () => setIsOnline(false);
    window.addEventListener("online", goOnline);
    window.addEventListener("offline", goOffline);
    return () => {
      window.removeEventListener("online", goOnline);
      window.removeEventListener("offline", goOffline);
    };
  }, []);

  return isOnline;
}

12. Returning Functions 🔧

Code Snippet

function useClipboard() {
  function copyToClipboard(text) {
    navigator.clipboard.writeText(text);
  }

  return copyToClipboard;
}

// Usage
function ShareButton({ url }) {
  const copy = useClipboard();
  return <button onClick={() => copy(url)}>Copy Link</button>;
}

13. Returning Objects 📦

When a Hook returns several related values, an object with named properties is often clearer than a positional array — especially as the number of returned values grows.

Code Snippet

function useForm(initialValues) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});

  function handleChange(e) {
    setValues({ ...values, [e.target.name]: e.target.value });
  }

  return { values, errors, handleChange, setErrors };
}

// Usage — order doesn't matter, names are self-documenting
const { values, handleChange } = useForm({ email: "" });

14. Returning Arrays 📚

For Hooks with two closely related return values (mirroring useState's pattern), an array lets consumers freely rename the destructured variables.

Code Snippet

function useToggle(initial = false) {
  const [value, setValue] = useState(initial);
  const toggle = () => setValue((v) => !v);
  return [value, toggle];
}

// Consumers can rename freely, just like useState
const [isOpen, toggleOpen] = useToggle();
const [isVisible, toggleVisible] = useToggle(true);

Tip

Use an array return for two tightly coupled values that consumers commonly rename; use an object return for three or more values, or when names shouldn't change.

15. Passing Arguments 📥

Code Snippet

function useFetch(url, options = {}) {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    setIsLoading(true);
    fetch(url, options)
      .then((res) => res.json())
      .then(setData)
      .catch(setError)
      .finally(() => setIsLoading(false));
  }, [url]);

  return { data, error, isLoading };
}

// Usage
const { data, isLoading } = useFetch("/api/products");

16. Using Multiple Custom Hooks 🔗

Code Snippet

function ProfilePage({ userId }) {
  const { user, isLoading } = useFetch(`/api/users/${userId}`);
  const [theme] = useLocalStorage("theme", "light");
  const isOnline = useIsOnline();

  if (isLoading) return <Spinner />;

  return (
    <div className={theme}>
      <h1>{user.name}</h1>
      <p>{isOnline ? "🟢 Online" : "🔴 Offline"}</p>
    </div>
  );
}

17. Composing Custom Hooks 🧬

Custom Hooks can call other custom Hooks, allowing complex behavior to be built from smaller, well-tested pieces — the same principle as composing components.

Code Snippet

function useDebouncedSearch(query, delay = 300) {
  const debouncedQuery = useDebounce(query, delay);
  const { data, isLoading } = useFetch(`/api/search?q=${debouncedQuery}`);
  return { results: data, isLoading };
}

Best Practice

Composing small, single-purpose Hooks together keeps each layer of logic easy to test, reason about, and reuse independently.

18. Error Handling in Custom Hooks ⚠️

Code Snippet

function useFetch(url) {
  const [state, setState] = useState({ data: null, error: null, isLoading: true });

  useEffect(() => {
    let ignore = false;
    setState((s) => ({ ...s, isLoading: true, error: null }));

    fetch(url)
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP error ${res.status}`);
        return res.json();
      })
      .then((data) => {
        if (!ignore) setState({ data, error: null, isLoading: false });
      })
      .catch((error) => {
        if (!ignore) setState({ data: null, error, isLoading: false });
      });

    return () => { ignore = true; };
  }, [url]);

  return state;
}

Important

Always account for the error state in data-fetching Hooks — consumers need a way to know something went wrong, not just that loading finished.

19. Async Custom Hooks ⏳

Custom Hooks can't be async functions themselves (since Hooks must run synchronously during render), but they commonly wrap internal async logic inside useEffect or event handlers.

Code Snippet

function useAsyncAction() {
  const [isPending, setIsPending] = useState(false);
  const [error, setError] = useState(null);

  async function run(action) {
    setIsPending(true);
    setError(null);
    try {
      await action();
    } catch (err) {
      setError(err);
    } finally {
      setIsPending(false);
    }
  }

  return { run, isPending, error };
}

20. Custom Hooks with Context 🌐

Wrapping a useContext call inside a custom Hook creates a clean, safer API — including a helpful error if used outside its Provider.

Code Snippet

const AuthContext = createContext(null);

function useAuth() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error("useAuth must be used within an AuthProvider");
  }
  return context;
}

// Usage — clean and safe
function Profile() {
  const { user, logout } = useAuth();
}

21. Custom Hooks with Reducers 🏗️

Code Snippet

function todosReducer(state, action) {
  switch (action.type) {
    case "add": return [...state, action.payload];
    case "remove": return state.filter((t) => t.id !== action.payload);
    default: return state;
  }
}

function useTodos() {
  const [todos, dispatch] = useReducer(todosReducer, []);

  const addTodo = (text) => dispatch({ type: "add", payload: { id: crypto.randomUUID(), text } });
  const removeTodo = (id) => dispatch({ type: "remove", payload: id });

  return { todos, addTodo, removeTodo };
}

Tip

Wrapping a reducer inside a custom Hook hides the dispatch/action details entirely, exposing only a simple, descriptive function-based API to consumers.

22. Custom Hooks with TypeScript 🔷

Code Snippet

interface UseFetchResult<T> {
  data: T | null;
  error: Error | null;
  isLoading: boolean;
}

function useFetch<T>(url: string): UseFetchResult<T> {
  const [data, setData] = useState<T | null>(null);
  const [error, setError] = useState<Error | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    fetch(url)
      .then((res) => res.json())
      .then((json: T) => setData(json))
      .catch(setError)
      .finally(() => setIsLoading(false));
  }, [url]);

  return { data, error, isLoading };
}

// Usage with explicit type
const { data } = useFetch<Product[]>("/api/products");

Tip

Making a custom Hook generic (<T>) lets it stay reusable across many different data shapes while preserving full type safety for each consumer.

23. Testing Custom Hooks 🧪

Custom Hooks can be tested in isolation using utilities like @testing-library/react's renderHook, without needing to render a full component.

Code Snippet

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

test('increments the counter', () => {
  const { result } = renderHook(() => useCounter(0));

  act(() => {
    result.current.increment();
  });

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

Best Practice

Testing Hooks independently of the components that use them makes tests faster to write and less brittle against unrelated UI changes.

24. Performance Considerations ⚡

  • Each component calling a custom Hook gets its own independent state — no state is shared automatically.
  • Memoize returned functions with useCallback if consumers may pass them to memo-wrapped components.
  • Avoid creating new objects/arrays on every call if the Hook is used inside performance-sensitive components.

Note

A custom Hook is not a singleton — calling it in five different components creates five entirely separate instances of its internal state.

25. Organizing Custom Hooks 🗂️

src/
hooks/
components/
useFetch.js
useLocalStorage.js
useDebounce.js
auth/
useAuth.js

Tip

Group generic, app-wide Hooks in a shared hooks/ folder, and colocate feature-specific Hooks alongside the components that use them.

26. Common Custom Hook Patterns 🎨

Code Snippet

function useDebounce(value, delay) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const timeout = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(timeout);
  }, [value, delay]);

  return debounced;
}

Code Snippet

function usePrevious(value) {
  const ref = useRef();
  useEffect(() => { ref.current = value; }, [value]);
  return ref.current;
}

Code Snippet

function useMediaQuery(query) {
  const [matches, setMatches] = useState(() => window.matchMedia(query).matches);

  useEffect(() => {
    const mql = window.matchMedia(query);
    const handler = (e) => setMatches(e.matches);
    mql.addEventListener("change", handler);
    return () => mql.removeEventListener("change", handler);
  }, [query]);

  return matches;
}

27. Best Practices 🌟

  1. Always prefix custom Hook names with use.
  2. Keep each Hook focused on one clear responsibility.
  3. Return objects for many values, arrays for two tightly-coupled values.
  4. Handle loading and error states explicitly in async Hooks.
  5. Compose smaller Hooks together rather than writing one large, monolithic Hook.
  6. Test Hooks independently using tools like renderHook.

28. Common Mistakes 🚫

  • Forgetting the use prefix, breaking linting and the Rules of Hooks enforcement.
  • Assuming custom Hooks share state between components — each call is fully independent.
  • Building overly generic, do-everything Hooks instead of composing smaller, focused ones.
  • Not handling error states in data-fetching Hooks.
  • Calling Hooks conditionally inside a custom Hook, violating the Rules of Hooks just like in components.

Danger

A common misconception is that two components calling the same custom Hook will see each other's state changes — in reality, each call creates a fully separate, isolated instance.

29. Frequently Asked Questions ❓

Question

Do custom Hooks add extra HTML or affect rendering?

Answer

No. Custom Hooks are purely logic — they don't render anything themselves and add no elements to the DOM.

Question

Can a custom Hook call another custom Hook?

Answer

Yes — this is called composition, and it's one of the most powerful aspects of the Hooks pattern, letting complex behavior be built from small, reusable pieces.

Question

How do I know when to extract logic into a custom Hook?

Answer

If the same useState/useEffect logic appears in two or more components, or if a component's logic is complex enough to obscure its rendering code, it's a good candidate for extraction.

30. Summary 📝

Custom Hooks unlock React's full potential for logic reuse, letting you package stateful behavior — from simple toggles to complex data-fetching and authentication flows — into clean, testable, composable functions. Mastering this pattern is often the difference between a codebase full of duplicated logic and one that stays clean as it scales.

Summary

With custom Hooks covered, natural next steps include exploring Performance Optimization techniques across the whole application, and Testing strategies for both Hooks and the components that consume them.