Mastering React Hooks & State: A Practical Guide
react javascript hooks state management frontend

Mastering React Hooks & State: A Practical Guide

D. Rout

D. Rout

March 2, 2026 8 min read

On this page

React Hooks changed everything. Introduced in React 16.8, they let you use state and other React features in function components — no class components required. Whether you're just getting started or looking to sharpen your fundamentals, this guide walks through the essential hooks with real, runnable examples.


What Are Hooks?

Hooks are functions that let you "hook into" React features like state and lifecycle methods from within function components. They follow two simple rules:

  1. Only call hooks at the top level — not inside loops, conditions, or nested functions.
  2. Only call hooks from React function components (or other custom hooks).

useState — Managing Local State

useState is the most fundamental hook. It returns a state variable and a setter function.

import { useState } from 'react';
 
function Counter() {
  const [count, setCount] = useState(0);
 
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <button onClick={() => setCount(count - 1)}>Decrement</button>
      <button onClick={() => setCount(0)}>Reset</button>
    </div>
  );
}

State with Objects

When state is an object, always spread the previous state to avoid losing other fields:

function UserForm() {
  const [user, setUser] = useState({ name: '', email: '' });
 
  const handleChange = (e) => {
    setUser(prev => ({
      ...prev,
      [e.target.name]: e.target.value,
    }));
  };
 
  return (
    <form>
      <input name="name" value={user.name} onChange={handleChange} placeholder="Name" />
      <input name="email" value={user.email} onChange={handleChange} placeholder="Email" />
      <p>Hello, {user.name || 'stranger'}!</p>
    </form>
  );
}

Functional Updates

When the new state depends on the previous state, use the functional form of the setter. This ensures correctness even in async or batched contexts:

// ❌ Potentially stale
setCount(count + 1);
 
// ✅ Always correct
setCount(prev => prev + 1);

useEffect — Side Effects & Lifecycle

useEffect runs side effects after rendering — things like data fetching, subscriptions, or manually touching the DOM.

import { useState, useEffect } from 'react';
 
function GitHubUser({ username }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
 
  useEffect(() => {
    setLoading(true);
    fetch(`https://api.github.com/users/${username}`)
      .then(res => res.json())
      .then(data => {
        setUser(data);
        setLoading(false);
      });
  }, [username]); // Re-runs whenever `username` changes
 
  if (loading) return <p>Loading...</p>;
  return <p>{user?.name} has {user?.public_repos} public repos.</p>;
}

The Dependency Array

The second argument to useEffect controls when it runs:

Dependency Array When Effect Runs
Omitted After every render
[] Once, on mount only
[a, b] When a or b changes

Cleanup

Return a cleanup function to cancel subscriptions, timers, or anything that could cause a memory leak:

useEffect(() => {
  const id = setInterval(() => {
    console.log('tick');
  }, 1000);
 
  // Cleanup: runs before the next effect or on unmount
  return () => clearInterval(id);
}, []);

useReducer — Complex State Logic

When state logic becomes complex — multiple sub-values, or the next state depends on the previous — useReducer is often a better fit than useState.

import { useReducer } from 'react';
 
const initialState = { count: 0, step: 1 };
 
function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { ...state, count: state.count + state.step };
    case 'decrement':
      return { ...state, count: state.count - state.step };
    case 'setStep':
      return { ...state, step: action.payload };
    case 'reset':
      return initialState;
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}
 
function StepCounter() {
  const [state, dispatch] = useReducer(reducer, initialState);
 
  return (
    <div>
      <p>Count: {state.count} (step: {state.step})</p>
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
      <input
        type="number"
        value={state.step}
        onChange={e => dispatch({ type: 'setStep', payload: Number(e.target.value) })}
      />
      <button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
    </div>
  );
}

Rule of thumb: Start with useState. Reach for useReducer when you have 3+ related state values or complex transitions.


useRef — Persistent Values Without Re-renders

useRef gives you a mutable container that persists across renders — without triggering a re-render when changed. Common use cases: DOM references and storing previous values.

import { useState, useEffect, useRef } from 'react';
 
function Timer() {
  const [seconds, setSeconds] = useState(0);
  const [running, setRunning] = useState(false);
  const intervalRef = useRef(null);
 
  useEffect(() => {
    if (running) {
      intervalRef.current = setInterval(() => {
        setSeconds(prev => prev + 1);
      }, 1000);
    } else {
      clearInterval(intervalRef.current);
    }
    return () => clearInterval(intervalRef.current);
  }, [running]);
 
  return (
    <div>
      <p>{seconds}s</p>
      <button onClick={() => setRunning(r => !r)}>
        {running ? 'Pause' : 'Start'}
      </button>
      <button onClick={() => { setRunning(false); setSeconds(0); }}>Reset</button>
    </div>
  );
}

Accessing DOM Elements

function AutoFocusInput() {
  const inputRef = useRef(null);
 
  useEffect(() => {
    inputRef.current?.focus();
  }, []);
 
  return <input ref={inputRef} placeholder="Auto-focused!" />;
}

useMemo & useCallback — Performance Optimisation

These hooks let you memoize computed values and functions — useful when passing callbacks to deeply nested children or performing expensive computations.

import { useState, useMemo, useCallback } from 'react';
 
function ExpensiveList({ items, onSelect }) {
  const sorted = useMemo(() => {
    // Only recalculates when `items` changes
    return [...items].sort((a, b) => a.localeCompare(b));
  }, [items]);
 
  return (
    <ul>
      {sorted.map(item => (
        <li key={item} onClick={() => onSelect(item)}>{item}</li>
      ))}
    </ul>
  );
}
 
function Parent() {
  const [items] = useState(['banana', 'apple', 'cherry']);
  const [selected, setSelected] = useState(null);
 
  // Stable reference — won't cause ExpensiveList to re-render unnecessarily
  const handleSelect = useCallback((item) => {
    setSelected(item);
  }, []);
 
  return (
    <>
      <ExpensiveList items={items} onSelect={handleSelect} />
      {selected && <p>Selected: {selected}</p>}
    </>
  );
}

Caution: Don't over-optimise. useMemo and useCallback add overhead themselves. Only reach for them when you have a measured performance problem.


Building Custom Hooks

Custom hooks let you extract and share stateful logic between components. They're just functions whose names start with use.

useFetch — Reusable Data Fetching

import { useState, useEffect } from 'react';
 
function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
 
  useEffect(() => {
    let cancelled = false;
    setLoading(true);
    setError(null);
 
    fetch(url)
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then(json => {
        if (!cancelled) {
          setData(json);
          setLoading(false);
        }
      })
      .catch(err => {
        if (!cancelled) {
          setError(err.message);
          setLoading(false);
        }
      });
 
    return () => { cancelled = true; };
  }, [url]);
 
  return { data, loading, error };
}
 
// Usage
function PostList() {
  const { data: posts, loading, error } = useFetch('https://jsonplaceholder.typicode.com/posts');
 
  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error}</p>;
 
  return (
    <ul>
      {posts.slice(0, 5).map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

useLocalStorage — Persisting State

import { useState } from 'react';
 
function useLocalStorage(key, initialValue) {
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch {
      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];
}
 
// Usage — works just like useState, but persists across page refreshes
function ThemeToggle() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');
 
  return (
    <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
      Current theme: {theme}
    </button>
  );
}

Common Mistakes to Avoid

1. Missing dependencies in useEffect Always include every value from the component scope that the effect uses. The ESLint plugin eslint-plugin-react-hooks will catch these automatically.

2. Infinite loops Setting state inside a useEffect with no dependency array — or with a dependency that changes on every render (like an inline object) — will cause infinite loops.

// ❌ Infinite loop — new object reference on every render
useEffect(() => { ... }, [{ id: 1 }]);
 
// ✅ Stable primitive
useEffect(() => { ... }, [userId]);

3. Calling hooks conditionally Hooks must always be called in the same order on every render.

// ❌ Breaks the rules of hooks
if (isLoggedIn) {
  const [user, setUser] = useState(null);
}
 
// ✅ Call unconditionally, use the value conditionally
const [user, setUser] = useState(null);
if (isLoggedIn) { /* use user */ }

Further Learning

Ready to go deeper? These resources are worth bookmarking:


Summary

Hook Use It For
useState Simple local state
useEffect Side effects, data fetching, subscriptions
useReducer Complex state with multiple transitions
useRef DOM access, mutable values without re-renders
useMemo Memoising expensive computed values
useCallback Stable function references for child components
Custom hooks Reusable stateful logic across components

Hooks reward a functional, composable way of thinking. Start simple with useState and useEffect, extract logic into custom hooks as patterns emerge, and reach for useReducer when state transitions get complex. The best React code reads like a series of small, focused hooks that each do one thing well.

Share

Comments (0)

Join the conversation

Sign in to leave a comment on this post.

No comments yet. to be the first!