Docs LogoDocs

Custom Hooks - Reusable Logic

Documentation for Custom Hooks - Reusable Logic.

Custom Hooks - Reusable Logic

What are Custom Hooks?

Custom hooks are JavaScript functions that use React hooks to share reusable logic between components.

Definition: Custom hooks are functions that start with "use" and can call other hooks. They let you extract component logic into reusable functions. Unlike components, they don't return JSX - they return values, functions, or objects that components can use. Custom hooks are the modern way to share stateful logic in React.

Why Use Custom Hooks?

BenefitExplanation
Code ReuseShare logic across components
Separation of ConcernsExtract complex logic from components
TestabilityTest logic independently
ReadabilityClean, focused components
CompositionCombine hooks for complex behavior

Rules of Hooks

┌─────────────────────────────────────────────────────────┐
│                   Rules of Hooks                        │
├─────────────────────────────────────────────────────────┤
│                                                         │
│   1. Only call hooks at the TOP LEVEL                   │
│      - Not inside loops, conditions, or nested functions│
│                                                         │
│   2. Only call hooks from REACT FUNCTIONS               │
│      - Function components                              │
│      - Custom hooks                                     │
│      - NOT regular JavaScript functions                 │
│                                                         │
│   3. Custom hook names must start with "use"            │
│      - useCounter, useFetch, useForm                    │
│                                                         │
└─────────────────────────────────────────────────────────┘

Basic Custom Hook

useCounter

// hooks/useCounter.js
import { useState, useCallback } from "react";

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

  const increment = useCallback(() => {
    setCount((c) => c + step);
  }, [step]);

  const decrement = useCallback(() => {
    setCount((c) => c - step);
  }, [step]);

  const reset = useCallback(() => {
    setCount(initialValue);
  }, [initialValue]);

  const set = useCallback((value) => {
    setCount(value);
  }, []);

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

export default useCounter;

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

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

useToggle

import { useState, useCallback } from "react";

function useToggle(initialValue = false) {
  const [value, setValue] = useState(initialValue);

  const toggle = useCallback(() => {
    setValue((v) => !v);
  }, []);

  const setTrue = useCallback(() => setValue(true), []);
  const setFalse = useCallback(() => setValue(false), []);

  return { value, toggle, setTrue, setFalse, setValue };
}

// Usage
function Modal() {
  const { value: isOpen, toggle, setFalse: close } = useToggle(false);

  return (
    <>
      <button onClick={toggle}>Open Modal</button>
      {isOpen && (
        <div className="modal">
          <p>Modal Content</p>
          <button onClick={close}>Close</button>
        </div>
      )}
    </>
  );
}

Data Fetching Hook

useFetch

import { useState, useEffect, useCallback } from "react";

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

  const fetchData = useCallback(async () => {
    setLoading(true);
    setError(null);

    try {
      const response = await fetch(url, options);

      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }

      const result = await response.json();
      setData(result);
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  }, [url, JSON.stringify(options)]);

  useEffect(() => {
    fetchData();
  }, [fetchData]);

  const refetch = useCallback(() => {
    fetchData();
  }, [fetchData]);

  return { data, loading, error, refetch };
}

// Usage
function UserList() {
  const { data: users, loading, error, refetch } = useFetch("/api/users");

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error}</p>;

  return (
    <div>
      <button onClick={refetch}>Refresh</button>
      <ul>
        {users?.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

useAsync

import { useState, useCallback } from "react";

function useAsync(asyncFunction) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  const execute = useCallback(
    async (...args) => {
      setLoading(true);
      setError(null);

      try {
        const result = await asyncFunction(...args);
        setData(result);
        return result;
      } catch (err) {
        setError(err);
        throw err;
      } finally {
        setLoading(false);
      }
    },
    [asyncFunction],
  );

  return { data, loading, error, execute };
}

// Usage
function CreateUser() {
  const { loading, error, execute } = useAsync(async (userData) => {
    const response = await fetch("/api/users", {
      method: "POST",
      body: JSON.stringify(userData),
    });
    return response.json();
  });

  const handleSubmit = async (e) => {
    e.preventDefault();
    await execute({ name: "John", email: "john@example.com" });
  };

  return (
    <form onSubmit={handleSubmit}>
      <button disabled={loading}>
        {loading ? "Creating..." : "Create User"}
      </button>
      {error && <p>Error: {error.message}</p>}
    </form>
  );
}

DOM & Browser Hooks

useLocalStorage

import { useState, useEffect, useCallback } from "react";

function useLocalStorage(key, initialValue) {
  // Get stored value or use initial
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error("Error reading localStorage:", error);
      return initialValue;
    }
  });

  // Update localStorage when value changes
  const setValue = useCallback(
    (value) => {
      try {
        const valueToStore =
          value instanceof Function ? value(storedValue) : value;

        setStoredValue(valueToStore);
        window.localStorage.setItem(key, JSON.stringify(valueToStore));
      } catch (error) {
        console.error("Error setting localStorage:", error);
      }
    },
    [key, storedValue],
  );

  const removeValue = useCallback(() => {
    try {
      window.localStorage.removeItem(key);
      setStoredValue(initialValue);
    } catch (error) {
      console.error("Error removing localStorage:", error);
    }
  }, [key, initialValue]);

  return [storedValue, setValue, removeValue];
}

// Usage
function Settings() {
  const [theme, setTheme] = useLocalStorage("theme", "light");

  return (
    <button onClick={() => setTheme((t) => (t === "light" ? "dark" : "light"))}>
      Current: {theme}
    </button>
  );
}

useWindowSize

import { useState, useEffect } from "react";

function useWindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight,
  });

  useEffect(() => {
    const handleResize = () => {
      setSize({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    };

    window.addEventListener("resize", handleResize);
    return () => window.removeEventListener("resize", handleResize);
  }, []);

  return size;
}

// Usage
function ResponsiveLayout() {
  const { width, height } = useWindowSize();
  const isMobile = width < 768;

  return (
    <div>
      <p>
        Window: {width} x {height}
      </p>
      {isMobile ? <MobileNav /> : <DesktopNav />}
    </div>
  );
}

useOnClickOutside

import { useEffect, useRef } from "react";

function useOnClickOutside(ref, handler) {
  useEffect(() => {
    const listener = (event) => {
      // Do nothing if clicking ref's element or descendent
      if (!ref.current || ref.current.contains(event.target)) {
        return;
      }
      handler(event);
    };

    document.addEventListener("mousedown", listener);
    document.addEventListener("touchstart", listener);

    return () => {
      document.removeEventListener("mousedown", listener);
      document.removeEventListener("touchstart", listener);
    };
  }, [ref, handler]);
}

// Usage
function Dropdown() {
  const [isOpen, setIsOpen] = useState(false);
  const dropdownRef = useRef(null);

  useOnClickOutside(dropdownRef, () => setIsOpen(false));

  return (
    <div ref={dropdownRef}>
      <button onClick={() => setIsOpen(!isOpen)}>Menu</button>
      {isOpen && (
        <ul>
          <li>Option 1</li>
          <li>Option 2</li>
        </ul>
      )}
    </div>
  );
}

Form Hooks

useForm

import { useState, useCallback } from "react";

function useForm(initialValues, validate) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);

  const handleChange = useCallback((e) => {
    const { name, value, type, checked } = e.target;
    setValues((prev) => ({
      ...prev,
      [name]: type === "checkbox" ? checked : value,
    }));
  }, []);

  const handleBlur = useCallback(
    (e) => {
      const { name } = e.target;
      setTouched((prev) => ({ ...prev, [name]: true }));

      if (validate) {
        const validationErrors = validate(values);
        setErrors(validationErrors);
      }
    },
    [values, validate],
  );

  const handleSubmit = useCallback(
    (onSubmit) => async (e) => {
      e.preventDefault();
      setIsSubmitting(true);

      const validationErrors = validate ? validate(values) : {};
      setErrors(validationErrors);
      setTouched(
        Object.keys(values).reduce((acc, key) => {
          acc[key] = true;
          return acc;
        }, {}),
      );

      if (Object.keys(validationErrors).length === 0) {
        await onSubmit(values);
      }

      setIsSubmitting(false);
    },
    [values, validate],
  );

  const reset = useCallback(() => {
    setValues(initialValues);
    setErrors({});
    setTouched({});
  }, [initialValues]);

  return {
    values,
    errors,
    touched,
    isSubmitting,
    handleChange,
    handleBlur,
    handleSubmit,
    reset,
    setValues,
  };
}

// Usage
function SignupForm() {
  const validate = (values) => {
    const errors = {};
    if (!values.email) errors.email = "Required";
    else if (!/\S+@\S+\.\S+/.test(values.email)) {
      errors.email = "Invalid email";
    }
    if (!values.password) errors.password = "Required";
    else if (values.password.length < 6) {
      errors.password = "Min 6 characters";
    }
    return errors;
  };

  const {
    values,
    errors,
    touched,
    isSubmitting,
    handleChange,
    handleBlur,
    handleSubmit,
  } = useForm({ email: "", password: "" }, validate);

  const onSubmit = async (data) => {
    console.log("Submitting:", data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input
        name="email"
        value={values.email}
        onChange={handleChange}
        onBlur={handleBlur}
      />
      {touched.email && errors.email && <span>{errors.email}</span>}

      <input
        name="password"
        type="password"
        value={values.password}
        onChange={handleChange}
        onBlur={handleBlur}
      />
      {touched.password && errors.password && <span>{errors.password}</span>}

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "Submitting..." : "Sign Up"}
      </button>
    </form>
  );
}

Utility Hooks

useDebounce

import { useState, useEffect } from "react";

function useDebounce(value, delay = 500) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}

// Usage
function Search() {
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebounce(query, 300);

  useEffect(() => {
    if (debouncedQuery) {
      searchApi(debouncedQuery).then(setResults);
    }
  }, [debouncedQuery]);

  return (
    <input
      value={query}
      onChange={(e) => setQuery(e.target.value)}
      placeholder="Search..."
    />
  );
}

usePrevious

import { useRef, useEffect } from "react";

function usePrevious(value) {
  const ref = useRef();

  useEffect(() => {
    ref.current = value;
  }, [value]);

  return ref.current;
}

// Usage
function Counter() {
  const [count, setCount] = useState(0);
  const prevCount = usePrevious(count);

  return (
    <p>
      Now: {count}, Before: {prevCount}
    </p>
  );
}

Interview Questions & Answers

Q1: What is a custom hook and when should you create one?

A custom hook is a JavaScript function starting with "use" that can call other React hooks. Create one when: (1) You have duplicate stateful logic across components. (2) Component logic is complex and can be extracted. (3) You want to share non-visual behavior. (4) Testing isolated logic is easier. Custom hooks don't share state between components using them - each gets its own independent state. They're about reusing logic, not state.


Q2: What are the rules for creating custom hooks?

Rules: (1) Name must start with "use" (useCounter, useFetch). (2) Can only call hooks at the top level - not in loops, conditions, or nested functions. (3) Can only be called from React function components or other custom hooks. (4) Should follow the same rules as built-in hooks. The "use" prefix tells React it's a hook and enables linting. Breaking these rules causes unpredictable behavior because React relies on hook call order.


Q3: How do custom hooks share logic without sharing state?

Each component calling a custom hook gets its own independent state. The hook's useState, useRef, etc., create new instances for each component. What's shared is the logic and the hook's implementation - not the actual state values. Example: two components using useCounter() each have their own count value. If you need shared state, use context inside the hook or lift state to a common ancestor.


Q4: How do you test custom hooks?

Use @testing-library/react-hooks or render them in a test component. With testing library: const { result } = renderHook(() => useCounter()). Access values via result.current. Use act() to wrap updates: act(() => { result.current.increment(); }). Then assert: expect(result.current.count).toBe(1). Test edge cases, async behavior, and cleanup. Custom hooks are easier to test than component logic because they're isolated.


Q5: What's the difference between custom hooks and utility functions?

Custom hooks use React hooks (useState, useEffect, etc.) and must follow hook rules. Utility functions are pure JavaScript - no hooks, no React. Use hooks when you need: React state, effects, refs, or context. Use utilities for: calculations, formatting, validation logic. Example: formatDate(date) is a utility; useFetch(url) is a hook because it manages loading state and effects.


Q6: How do you handle dependencies in custom hooks?

Dependencies work the same as in components. Include all values from the hook's scope that are used inside useEffect/useCallback/useMemo. For parameters: if they change, effects should re-run. Use useCallback for exposed functions if consumers might use them in their own deps. For complex options, consider using a ref or memoizing internally. ESLint's exhaustive-deps rule helps catch issues.


Q7: How do you handle cleanup in custom hooks?

Return cleanup functions from useEffect inside the hook - React handles them automatically. For hooks managing subscriptions, timers, or event listeners, always clean up. Example: useEffect(() => { const id = setInterval(...); return () => clearInterval(id); }, []). The cleanup runs when the component using the hook unmounts or when dependencies change, preventing memory leaks.


Q8: What's the best way to organize custom hooks?

Common patterns: hooks/ folder in your project, one hook per file named after the hook (useFetch.js), export as default or named. Group related hooks together. Create an index.js for barrel exports. For complex hooks, consider co-locating tests (useFetch.test.js). Document parameters and return values. Some prefer use- prefix in filenames (use-fetch.js). Consistency within your project is key.


Q9: How do you make custom hooks configurable?

Accept configuration through parameters: useFetch(url, options). Support defaults: function useFetch(url, { method = 'GET', headers = {} } = {}). For complex config, accept an options object. Consider what should trigger re-execution (deps) vs what shouldn't (refs). Expose setters if config should change dynamically. Balance flexibility with simplicity - don't over-engineer.


Q10: When should you NOT create a custom hook?

Avoid custom hooks when: (1) Logic is only used in one place - YAGNI. (2) Logic doesn't use any hooks - make it a utility function. (3) It adds complexity without benefit. (4) The logic is trivial (one useState + one setter). (5) You're trying to hide implementation for "cleanliness" but it harms readability. Premature abstraction is as bad as premature optimization. Extract when there's actual repetition.

Last updated on July 15, 2026

On this page