Home
Back to Blogs
React Hooks Explored: Essential Strategies and Crafting Custom Solutions

React Hooks Explored: Essential Strategies and Crafting Custom Solutions

Murat Hüdavendigâr Öncü
January 10, 2024
9 min
#react#javascript#web-development#hooks#frontend

An in-depth exploration of React Hooks, covering core mechanics, performance optimization with useMemo/useCallback, and a complete guide to building reusable custom hooks.

Last updated on July 18, 2026

React Hooks revolutionized the way we build web applications by allowing us to use state and other React features without writing a class. Since their introduction, they have become the standard for functional components. Understanding the core hooks is only the first step; the real power lies in knowing when to use them and how to extract logic into custom hooks for cleaner, more maintainable code.

When I teach React to new frontend developers, hooks are the first place confusion shows up — especially useEffect dependency arrays and “when do I need a custom hook?” This guide collects the patterns I repeat most often in class and in production Next.js work at TemCraft Tech.

What Do the Core Hooks Do Beyond the Basics?

Most developers start with useState and useEffect. While they seem simple, they have nuances that can significantly impact performance and bug tracking.

useState: Managing Local State

useState is the most common hook. It allows you to add state to functional components. However, a common mistake is not using the functional update pattern when the new state depends on the previous one.

// Instead of this:
setCount(count + 1);

// Use this to ensure accuracy during rapid updates:
setCount(prevCount => prevCount + 1);

useEffect: Handling Side Effects

useEffect is where you handle API calls, subscriptions, or manual DOM manipulations. The dependency array is the most critical part of this hook. If you leave it out, the effect runs after every render. If you provide an empty array [], it runs once on mount. If you include variables, it runs whenever those variables change.

Always remember to return a cleanup function to prevent memory leaks, especially when dealing with timers or event listeners:

useEffect(() => {
  const timer = setInterval(() => {
    console.log('Tick');
  }, 1000);

  return () => clearInterval(timer); // Cleanup
}, []);

Optimization Hooks: useMemo and useCallback

Performance optimization in React often involves preventing unnecessary re-renders. This is where useMemo and useCallback come into play.

  • useMemo: Memoizes a calculated value. Use it for expensive calculations that don't need to run on every render.
  • useCallback: Memoizes a function instance. This is useful when passing functions to memoized child components to prevent them from breaking the child's React.memo optimization.

Note: Do not over-optimize. Using these hooks everywhere adds overhead. Only use them when you notice a performance bottleneck or when passing dependencies to other hooks.

React.memo is the third piece of this trio, and it's worth being explicit about what each does, since they're frequently confused:

ToolMemoizesUse it when
useMemoA computed valueAn expensive calculation shouldn't re-run every render
useCallbackA function referenceYou're passing a callback prop to a memoized child
React.memoAn entire componentA component re-renders with the same props too often

They work together, not in isolation: wrapping a child in React.memo only prevents re-renders if the props passed to it are stable — which is exactly what useCallback (for function props) and useMemo (for object/array props) provide.

useRef and useContext: The Other Two You'll Reach For Constantly

Beyond useState and useEffect, two more hooks show up in almost every non-trivial component.

useRef gives you a mutable box that persists across renders without triggering a re-render when it changes — unlike useState. It has two common uses: holding a reference to a DOM node, and holding any mutable value you want to survive re-renders without causing new ones.

function TextInputWithFocusButton() {
  const inputRef = useRef(null);

  const focusInput = () => {
    inputRef.current.focus();
  };

  return (
    <>
      <input ref={inputRef} type="text" />
      <button onClick={focusInput}>Focus the input</button>
    </>
  );
}

useContext solves "prop drilling" — passing a prop down through five layers of components that don't use it themselves, just to get it to a deeply nested child. A context provider makes a value available to any descendant that asks for it:

const ThemeContext = createContext('light');

function App() {
  return (
    <ThemeContext.Provider value="dark">
      <Toolbar />
    </ThemeContext.Provider>
  );
}

function Toolbar() {
  const theme = useContext(ThemeContext); // no props passed through Toolbar at all
  return <div className={`toolbar-${theme}`}>...</div>;
}

Reach for useContext when a value is genuinely global to a subtree (theme, authenticated user, locale) — not as a blanket replacement for passing props, which is still the simpler and more traceable option for most component communication.

The Power of Custom Hooks

Custom hooks are simply JavaScript functions whose names start with 'use' and that may call other hooks. They allow you to extract component logic into reusable functions. This is the ultimate strategy for keeping your components lean and focused on the UI.

Why Build Custom Hooks?

  1. Reusability: Use the same logic across multiple components without duplication.
  2. Testability: You can test the logic independently of the UI.
  3. Clean Code: It moves complex logic out of your main component body.

Crafting a Custom Solution: useFetch

Let's build a practical custom hook for fetching data. This hook will handle the loading state, the data itself, and any potential errors.

import { useState, useEffect } from 'react';

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]);

  return { data, loading, error };
}

export default useFetch;

Now, you can use this across your entire application like this:

const { data, loading, error } = useFetch('[https://api.example.com/items](https://api.example.com/items)');

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

return <div>{/* Render your data here */}</div>;

A Second Custom Hook: useDebounce

useFetch handles data loading, but a lot of real UI work involves reacting to fast-changing input — a search box that shouldn't fire an API call on every keystroke, for example. useDebounce is the standard pattern for that:

import { useState, useEffect } from 'react';

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

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

    return () => clearTimeout(timer); // cancel if value changes before delay elapses
  }, [value, delayMs]);

  return debouncedValue;
}

Used in a search component, it lets you keep the input responsive while delaying the expensive part (the API call) until the user pauses typing:

function SearchBox() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 400);

  useEffect(() => {
    if (!debouncedQuery) return;
    fetch(`/api/search?q=${debouncedQuery}`);
  }, [debouncedQuery]);

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

Notice the shape: query updates on every keystroke, but debouncedQuery only catches up 400ms after typing pauses — because each new keystroke cancels the previous setTimeout via the cleanup function before it can fire.

Common Pitfalls: Stale Closures

The single most confusing bug in hooks-based React is the stale closure — an effect or callback that captures a variable's value from the render it was created in, and keeps using that old value even after state has changed.

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

  useEffect(() => {
    const timer = setInterval(() => {
      console.log(count); // always logs 0, no matter how many times count changes
    }, 1000);

    return () => clearInterval(timer);
  }, []); // empty array means this effect (and its closure over `count`) runs once, forever

  return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>;
}

Because the dependency array is [], the effect runs exactly once, and the function inside it closes over the count value from that first render — 0 — forever. Even though count updates on screen with every click, the console.log inside the interval never sees the new value.

The fix is either to include count in the dependency array (which restarts the interval on every count change) or, more often, to use the functional update form so the callback doesn't need to read the stale variable at all:

useEffect(() => {
  const timer = setInterval(() => {
    setCount(prevCount => prevCount + 1); // reads current state via the updater, not the closure
  }, 1000);

  return () => clearInterval(timer);
}, []);

This is exactly the same functional-update pattern from the useState section above — it isn't a coincidence. Once you recognize that hooks close over the values present at render time, stale closures stop being mysterious and become a predictable thing to check for whenever an effect reads state without listing it as a dependency.

Essential Strategies for Success

To master hooks, follow these industry-standard rules:

  • Rules of Hooks: Only call hooks at the top level. Don’t call hooks inside loops, conditions, or nested functions. Only call hooks from React function components or custom hooks.
  • Keep Hooks Focused: A custom hook should do one thing well. If your custom hook is doing too many things, consider breaking it down into smaller, specialized hooks.
  • Dependency Accuracy: Always be honest with your dependency arrays. Tools like ESLint with the eslint-plugin-react-hooks can help you catch missing dependencies automatically.

Conclusion

React Hooks are more than just a syntax change; they represent a shift in how we think about state and side effects. By mastering built-in hooks and learning to architect your own custom solutions, you move from just writing code to designing robust, scalable systems. The key is to start simple, recognize patterns of repetition in your components, and extract those patterns into reusable logic.


Understanding the lifecycle and mental model of hooks is the hallmark of a senior React developer. Continue experimenting with different custom hook patterns to find what works best for your team's workflow.

Back to all posts