Bobby Encoded
PostsAbout
PostsAbout

© 2026 Bobby Jose

← Back to Blog

React State and Hooks: The Complete Guide

March 3, 2025 · 10 min read

React, Hooks, JavaScript, State Management, Interview Prep

Introduction

Hooks revolutionized React by bringing state and lifecycle features to functional components. Understanding hooks deeply is essential for writing production React code. This article covers the core hooks, their rules, and patterns you'll use daily.


useState: Managing Component State

useState is the foundation of state management in functional components.

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(prev => prev - 1)}>Decrement</button>
    </div>
  );
}

Understanding the Destructuring

setCount is a function that React creates for you - you're just naming it via array destructuring. You could call it anything:

const [count, updateCount] = useState(0);
const [name, setName] = useState('');
const [items, setItems] = useState([]);

The convention is [value, setValue] but it's just a naming pattern, not a requirement.

For C# Developers

If you're coming from C#, useState is similar to a property with INotifyPropertyChanged:

// C# with INotifyPropertyChanged (WPF/MAUI)
private int _count;
public int Count
{
    get => _count;
    set { _count = value; OnPropertyChanged(); }
}
// React equivalent
const [count, setCount] = useState(0);
// setCount(5) triggers re-render automatically

Key difference: In C#, you mutate the value and notify. In React, you never mutate - you always replace with a new value via the setter function.

Functional Updates

When new state depends on previous state, use the functional form:

// ❌ Can cause bugs with rapid updates
setCount(count + 1);
setCount(count + 1); // Still uses stale count!

// ✅ Always gets latest state
setCount(prev => prev + 1);
setCount(prev => prev + 1); // Correctly increments twice

Why Functional Updates Matter

React batches state updates for performance. When you call setCount(count + 1) twice, both calls see the same count value. The functional form prev => prev + 1 always receives the most recent state.

Complex State

function UserForm() {
  const [user, setUser] = useState({
    name: '',
    email: '',
    preferences: { theme: 'light', notifications: true }
  });

  const updateField = (field, value) => {
    setUser(prev => ({
      ...prev,
      [field]: value
    }));
  };

  const updatePreference = (key, value) => {
    setUser(prev => ({
      ...prev,
      preferences: {
        ...prev.preferences,
        [key]: value
      }
    }));
  };

  return (
    <form>
      <input
        value={user.name}
        onChange={e => updateField('name', e.target.value)}
      />
      {/* ... */}
    </form>
  );
}

Lazy Initialization

For expensive initial state calculations:

// ❌ Runs on every render
const [data, setData] = useState(expensiveCalculation());

// ✅ Only runs once on mount
const [data, setData] = useState(() => expensiveCalculation());

useEffect: Side Effects and Lifecycle

useEffect handles side effects: data fetching, subscriptions, DOM manipulation, and more.

useEffect(() => {
  // Effect runs after render

  return () => {
    // Cleanup runs before next effect or unmount
  };
}, [dependencies]);

Dependency Array Patterns

// Runs after every render
useEffect(() => {
  console.log('Rendered');
});

// Runs once on mount, cleanup on unmount
useEffect(() => {
  const subscription = api.subscribe();
  return () => subscription.unsubscribe();
}, []);

// Runs when userId changes
useEffect(() => {
  fetchUser(userId);
}, [userId]);

Data Fetching Pattern

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;

    async function fetchUser() {
      setLoading(true);
      setError(null);

      try {
        const response = await fetch(`/api/users/${userId}`);
        const data = await response.json();

        if (!cancelled) {
          setUser(data);
        }
      } catch (err) {
        if (!cancelled) {
          setError(err.message);
        }
      } finally {
        if (!cancelled) {
          setLoading(false);
        }
      }
    }

    fetchUser();

    return () => {
      cancelled = true;
    };
  }, [userId]);

  if (loading) return <Spinner />;
  if (error) return <Error message={error} />;
  return <Profile user={user} />;
}

Race Condition Prevention

The cancelled flag prevents setting state after the component unmounts or when a newer request should take precedence. This is a common source of bugs and memory leaks.

Event Listeners

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;
}

useRef: Mutable References

useRef provides a mutable container that persists across renders without causing re-renders.

DOM References

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

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

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

Storing Mutable Values

function Timer() {
  const [seconds, setSeconds] = useState(0);
  const intervalRef = useRef(null);

  const start = () => {
    if (intervalRef.current) return;

    intervalRef.current = setInterval(() => {
      setSeconds(s => s + 1);
    }, 1000);
  };

  const stop = () => {
    clearInterval(intervalRef.current);
    intervalRef.current = null;
  };

  useEffect(() => {
    return () => clearInterval(intervalRef.current);
  }, []);

  return (
    <div>
      <p>{seconds} seconds</p>
      <button onClick={start}>Start</button>
      <button onClick={stop}>Stop</button>
    </div>
  );
}

Previous Value Pattern

What it does: Stores the previous value of a state or prop, giving you access to what it was before the current render.

Why use it: Comparing current vs previous values - animations based on direction of change, detecting if a specific prop changed, undo functionality.

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

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

  return ref.current;
}

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

  return (
    <p>Current: {count}, Previous: {prevCount}</p>
  );
}

Rules of Hooks

These Rules Are Non-Negotiable

Breaking these rules causes bugs that are hard to debug. React relies on hook call order to maintain state correctly.

1. Only Call Hooks at the Top Level

// ❌ WRONG - conditional hook
function Bad({ condition }) {
  if (condition) {
    const [value, setValue] = useState(0); // Breaks hook order!
  }
}

// ✅ CORRECT - always call, conditionally use
function Good({ condition }) {
  const [value, setValue] = useState(0);

  if (condition) {
    // Use value here
  }
}

2. Only Call Hooks from React Functions

// ❌ WRONG - regular function
function regularFunction() {
  const [state, setState] = useState(0); // Not allowed!
}

// ✅ CORRECT - React component or custom hook
function useCustomHook() {
  const [state, setState] = useState(0);
  return [state, setState];
}

Custom Hooks

Custom hooks extract reusable logic. They're just functions that use other hooks.

useLocalStorage

What it does: Syncs state with browser's localStorage, so data persists across page refreshes and browser sessions.

Why use it: User preferences (theme, language), form drafts, shopping cart items - anything you want to survive a page reload without a backend.

function useLocalStorage(key, initialValue) {
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      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
function Settings() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');

  return (
    <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
      Toggle Theme
    </button>
  );
}

useDebounce

What it does: Delays updating a value until the user stops changing it for a specified time (e.g., 300ms).

Why use it: Search inputs. Without debouncing, typing "react hooks" fires 11 API calls (one per keystroke). With debouncing, it waits until you stop typing, then fires once. Saves API calls and improves performance.

function useDebounce(value, delay) {
  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);
    }
  }, [debouncedQuery]);

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

Custom Hooks Are Your Secret Weapon

Building Glucoplate, I found custom hooks invaluable for keeping components clean. Hooks like useDebounce and useLocalStorage get reused constantly. Extract logic into hooks early - it pays off quickly.

useToggle

What it does: Manages boolean state with convenient toggle/setTrue/setFalse functions.

Why use it: Modals, dropdowns, sidebars, any show/hide UI. Cleaner than writing setIsOpen(!isOpen) everywhere.

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 };
}

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

  return (
    <>
      <button onClick={toggle}>Open Modal</button>
      {isOpen && <ModalContent onClose={close} />}
    </>
  );
}

Questions Worth Considering

Q1: What's the difference between useEffect and useLayoutEffect?

Answer:

  • useEffect runs asynchronously after the browser paints
  • useLayoutEffect runs synchronously before the browser paints

Use useLayoutEffect when you need to measure DOM elements or make visual changes that should happen before the user sees anything. For most cases, useEffect is preferred to avoid blocking the paint.

Q2: Why can't hooks be called conditionally?

Answer: React tracks hooks by their call order. Each render must call hooks in the exact same order. If a hook is called conditionally, the order could change between renders, causing React to associate the wrong state with the wrong hook.

Q3: How do you prevent useEffect from running on initial render?

Answer: Use a ref to track if it's the first render:

function useUpdateEffect(effect, deps) {
  const isFirstRender = useRef(true);

  useEffect(() => {
    if (isFirstRender.current) {
      isFirstRender.current = false;
      return;
    }
    return effect();
  }, deps);
}

Q4: What causes infinite loops with useEffect?

Answer: Creating new objects/arrays in the dependency array or updating state that's in the dependency array:

// ❌ Infinite loop - object recreated every render
useEffect(() => {
  fetch(options); // options is { url: '...' }
}, [options]); // New object each time!

// ✅ Fix - stable reference or primitive deps
const url = options.url;
useEffect(() => {
  fetch({ url });
}, [url]);

Common Mistakes

1. Missing Dependencies

// ❌ ESLint will warn - missing userId
useEffect(() => {
  fetchUser(userId);
}, []); // userId should be in deps

// ✅ CORRECT
useEffect(() => {
  fetchUser(userId);
}, [userId]);

2. Object/Array Dependencies

// ❌ Creates new object every render
useEffect(() => {
  doSomething(config);
}, [{ key: value }]); // Always "new"

// ✅ Use primitive values or useMemo
const config = useMemo(() => ({ key: value }), [value]);
useEffect(() => {
  doSomething(config);
}, [config]);

3. Stale Closures

// ❌ count is stale in the interval
useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1); // Always uses initial count!
  }, 1000);
  return () => clearInterval(id);
}, []);

// ✅ Use functional update
useEffect(() => {
  const id = setInterval(() => {
    setCount(c => c + 1); // Always gets current value
  }, 1000);
  return () => clearInterval(id);
}, []);

Summary

  • useState manages local component state with functional updates for derived state
  • useEffect handles side effects with proper cleanup and dependency tracking
  • useRef stores mutable values without triggering re-renders
  • Rules of Hooks must be followed: top-level only, React functions only
  • Custom hooks extract and share stateful logic across components

Next up: Component Patterns - composition, compound components, render props, and HOCs.


Part 2 of the React Developer Reference series.

← Previous

React Component Patterns: Composition and Reusability

Next →

React Foundations: JSX, Components, and Props