Back to Articles
ReactFebruary 24, 202610 min read

Mastering React Hooks: From Basics to Custom Hooks (2026 Guide)

Mastering React Hooks: From Basics to Custom Hooks (2026 Guide)

React Hooks transformed how we write React applications. Before hooks, sharing stateful logic required complex HOC patterns that created deeply nested "wrapper hell." With hooks, that same logic lives in a simple, composable function.

01 useState — More Than You Think

The golden rule: when new state depends on previous state, always use the functional update form to avoid stale closure bugs — especially inside async callbacks and rapid event sequences.

// ❌ BAD — can produce stale state
setCount(count + 1);

// ✅ GOOD — always reads the latest value
setCount(prev => prev + 1);

// ✅ Lazy initialization for expensive computations
const [filters, setFilters] = useState(
  () => parseFilters(window.location.search)
);

02 useEffect — The Right Way

Every value from component scope used inside useEffect must be in the dependency array. Always return a cleanup function for subscriptions, timers, and async fetch calls.

useEffect(() => {
  const controller = new AbortController();

  fetch(`/api/users/${userId}`, { signal: controller.signal })
    .then(res => res.json())
    .then(setUser)
    .catch(err => {
      if (err.name !== 'AbortError') throw err;
    });

  // ✅ Cleanup cancels in-flight request on unmount
  return () => controller.abort();
}, [userId]);

💡 Separation of Concerns

Split unrelated logic into separate useEffect calls. Each effect should do exactly one thing — making it dramatically easier to debug and reason about.

03 useRef — Beyond DOM References

useRef stores mutable values that persist across renders without triggering a re-render. This makes it perfect for tracking previous values, storing stable callbacks, and checking if a component is still mounted.

// Track previous value across renders
const usePrevious = (value) => {
  const ref = useRef(undefined);
  useEffect(() => { ref.current = value; });
  return ref.current; // value from previous render
};

// Check if component is still mounted before setState
const useIsMounted = () => {
  const isMounted = useRef(false);
  useEffect(() => {
    isMounted.current = true;
    return () => { isMounted.current = false; };
  }, []);
  return isMounted;
};

04 useReducer — Complex State Logic

When state has 4+ related fields or interdependent update logic, useReducer is far cleaner than multiple useState calls. It centralizes all transitions in one predictable function.

const feedReducer = (state, action) => {
  switch (action.type) {
    case 'FETCH_START':
      return { ...state, loading: true, error: null };
    case 'FETCH_SUCCESS':
      return {
        ...state,
        loading: false,
        items: [...state.items, ...action.payload.items],
        page: state.page + 1,
        hasMore: action.payload.items.length === 20,
      };
    case 'FETCH_ERROR':
      return { ...state, loading: false, error: action.payload };
    default:
      return state;
  }
};

05 Custom Hooks — The Real Power

Custom hooks are where hooks truly shine. They let you extract, reuse, and test stateful logic completely independently of any UI component. Here are four production-ready examples:

  • useFetch: Data fetching with loading, error, and abort signal handling.
  • useLocalStorage: A drop-in useState replacement that persists data.
  • useDebounce: Throttles expensive operations like search inputs by 300-400ms.
  • useForm: Reusable form logic with validation, touched state, and async submission.
// useDebounce — perfect for search inputs
const useDebounce = (value, delay = 300) => {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delay);
    return () => clearTimeout(timer); // cleanup on every keystroke
  }, [value, delay]);

  return debouncedValue;
};

// Usage
const debouncedQuery = useDebounce(searchInput, 400);
const { data } = useFetch(`/api/search?q=${debouncedQuery}`);

⚡ Hook Best Practices Checklist

Only call hooks at the top level (never in loops or conditions) · Every value used in useEffect belongs in the dependency array · Always return cleanup for subscriptions and timers · Prefix custom hooks with use · Profile before memoizing — don't optimize blindly.

Final Thoughts

Mastering hooks isn't about memorizing their API — it's about understanding when and why to reach for each one. Once you start extracting logic into custom hooks like useFetch, useForm, and useDebounce, your components become dramatically simpler — pure expressions of what to render, with all the complex logic neatly tucked away in composable, testable functions.

Want more insights?

Subscribe to my newsletter to get the latest technical articles, case studies, and development tips delivered straight to your inbox.