Reactbeginner

useState Hook Complete Guide

Master React's useState hook: syntax, initialization, functional updates, state batching, object/array state, common pitfalls, and custom hooks. Everything you need to manage component state.

16 min readยทPublished Mar 14, 2026
reacthooksusestatestate-management

What Is useState?

useState is a React hook that lets you add state to function components. Before hooks (React 16.8), only class components could hold state. Now, useState is the primary way to manage local component state.

import { useState } from 'react';

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

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

When you call setCount, React schedules a re-render. The component function runs again, and useState returns the new value.

Syntax and Initialization

Basic Syntax

const [stateValue, setterFunction] = useState(initialValue);
  • stateValue โ€” the current value of this state variable
  • setterFunction โ€” a function to update the state and trigger a re-render
  • initialValue โ€” the value used on the first render only

The names are arbitrary (array destructuring), but the convention is [thing, setThing].

Types of Initial Values

useState accepts any JavaScript value as the initial state.

// Primitive values
const [count, setCount] = useState(0);
const [name, setName] = useState('');
const [isOpen, setIsOpen] = useState(false);
const [selectedId, setSelectedId] = useState(null);

// Objects
const [user, setUser] = useState({ name: 'Alice', age: 30 });

// Arrays
const [items, setItems] = useState([]);
const [todos, setTodos] = useState([
  { id: 1, text: 'Learn React', done: false },
  { id: 2, text: 'Build project', done: false },
]);

Lazy Initialization

If computing the initial value is expensive, pass a function instead of a value. React will only call this function on the first render.

// BAD โ€” createInitialState() runs on EVERY render (but result is ignored after first)
const [state, setState] = useState(createExpensiveInitialState());

// GOOD โ€” function only called on first render
const [state, setState] = useState(() => createExpensiveInitialState());

Real-world example: reading from localStorage.

function usePersistentCounter() {
  const [count, setCount] = useState(() => {
    // Only runs once, on mount
    const saved = localStorage.getItem('counter');
    return saved !== null ? JSON.parse(saved) : 0;
  });

  useEffect(() => {
    localStorage.setItem('counter', JSON.stringify(count));
  }, [count]);

  return [count, setCount];
}
Without lazy init:
  Render 1: JSON.parse(localStorage.getItem('counter'))  <-- used
  Render 2: JSON.parse(localStorage.getItem('counter'))  <-- wasted
  Render 3: JSON.parse(localStorage.getItem('counter'))  <-- wasted

With lazy init (pass function):
  Render 1: () => JSON.parse(...)  <-- called, result used
  Render 2: (function ignored)     <-- no work done
  Render 3: (function ignored)     <-- no work done

Multiple State Variables vs Single Object

function SignupForm() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [agreeToTerms, setAgreeToTerms] = useState(false);

  return (
    <form>
      <input value={name} onChange={e => setName(e.target.value)} />
      <input value={email} onChange={e => setEmail(e.target.value)} />
      <input
        type="password"
        value={password}
        onChange={e => setPassword(e.target.value)}
      />
      <label>
        <input
          type="checkbox"
          checked={agreeToTerms}
          onChange={e => setAgreeToTerms(e.target.checked)}
        />
        I agree
      </label>
    </form>
  );
}
function SignupForm() {
  const [form, setForm] = useState({
    name: '',
    email: '',
    password: '',
    agreeToTerms: false,
  });

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

  return (
    <form>
      <input
        value={form.name}
        onChange={e => updateField('name', e.target.value)}
      />
      <input
        value={form.email}
        onChange={e => updateField('email', e.target.value)}
      />
      <input
        type="password"
        value={form.password}
        onChange={e => updateField('password', e.target.value)}
      />
      <label>
        <input
          type="checkbox"
          checked={form.agreeToTerms}
          onChange={e => updateField('agreeToTerms', e.target.checked)}
        />
        I agree
      </label>
    </form>
  );
}

When to Use Which

ApproachUse When
Multiple useStateValues change independently, simple types
Single object useStateValues are related, change together, or there are many fields
useReducerComplex state logic, multiple actions, state transitions

Updating State

Direct Updates

For simple cases where the new value doesn't depend on the old value:

setCount(5);
setName('Bob');
setIsOpen(true);
setItems([1, 2, 3]);

Functional Updates

When the new state depends on the previous state, pass a function. This avoids stale closure issues.

// Direct update โ€” uses the value of count from this render's closure
setCount(count + 1);

// Functional update โ€” receives the latest state value
setCount(prevCount => prevCount + 1);

Why functional updates matter:

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

  const incrementThrice = () => {
    // BUG: All three read the same `count` from closure (e.g., 0)
    setCount(count + 1);  // 0 + 1 = 1
    setCount(count + 1);  // 0 + 1 = 1 (count is still 0 in this closure)
    setCount(count + 1);  // 0 + 1 = 1
    // Result: count = 1, NOT 3
  };

  const incrementThriceCorrect = () => {
    // CORRECT: Each receives the latest pending state
    setCount(c => c + 1);  // 0 -> 1
    setCount(c => c + 1);  // 1 -> 2
    setCount(c => c + 1);  // 2 -> 3
    // Result: count = 3
  };

  return (
    <div>
      <p>{count}</p>
      <button onClick={incrementThrice}>+3 (buggy)</button>
      <button onClick={incrementThriceCorrect}>+3 (correct)</button>
    </div>
  );
}

Rule of thumb: Use functional updates whenever the new state is derived from the old state.

State Batching

React batches multiple state updates within the same synchronous event handler into a single re-render for performance.

Batching in Event Handlers

function BatchDemo() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);
  const [text, setText] = useState('hello');

  const handleClick = () => {
    // All three updates are batched โ€” ONE re-render
    setCount(c => c + 1);
    setFlag(f => !f);
    setText('world');
    // Component re-renders once here, with all three updates applied
  };

  console.log('Rendered');  // Logs once per click

  return (
    <div>
      <p>{count} | {flag.toString()} | {text}</p>
      <button onClick={handleClick}>Update all</button>
    </div>
  );
}

Automatic Batching (React 18+)

Before React 18, batching only worked inside React event handlers. Since React 18, batching works everywhere:

function AutoBatchDemo() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);

  const handleClick = () => {
    // React 18: ALL of these are batched

    // Inside setTimeout โ€” batched since React 18
    setTimeout(() => {
      setCount(c => c + 1);
      setFlag(f => !f);
      // ONE re-render
    }, 1000);

    // Inside Promise โ€” batched since React 18
    fetch('/api/data').then(() => {
      setCount(c => c + 1);
      setFlag(f => !f);
      // ONE re-render
    });
  };

  return <button onClick={handleClick}>Async update</button>;
}

Opting Out of Batching (Rare)

If you need an update to happen immediately (before the next one), use flushSync:

import { flushSync } from 'react-dom';

function FlushExample() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);

  const handleClick = () => {
    flushSync(() => {
      setCount(c => c + 1);
    });
    // DOM is updated here, component has re-rendered

    flushSync(() => {
      setFlag(f => !f);
    });
    // DOM is updated again
  };
}

You almost never need flushSync. It's an escape hatch for rare cases like measuring DOM after an update.

Working with Objects and Arrays

Updating Objects (Never Mutate)

function UserProfile() {
  const [user, setUser] = useState({
    name: 'Alice',
    email: '[email protected]',
    preferences: {
      theme: 'dark',
      language: 'en',
    },
  });

  // Update a top-level field
  const updateName = (name) => {
    setUser(prev => ({ ...prev, name }));
  };

  // Update a nested field
  const updateTheme = (theme) => {
    setUser(prev => ({
      ...prev,
      preferences: {
        ...prev.preferences,
        theme,
      },
    }));
  };

  return (
    <div>
      <p>{user.name} โ€” {user.preferences.theme}</p>
      <button onClick={() => updateName('Bob')}>Change name</button>
      <button onClick={() => updateTheme('light')}>Toggle theme</button>
    </div>
  );
}

Updating Arrays

function TodoApp() {
  const [todos, setTodos] = useState([
    { id: 1, text: 'Learn React', done: false },
    { id: 2, text: 'Build app', done: false },
  ]);

  // Add item
  const addTodo = (text) => {
    setTodos(prev => [...prev, { id: Date.now(), text, done: false }]);
  };

  // Remove item
  const removeTodo = (id) => {
    setTodos(prev => prev.filter(todo => todo.id !== id));
  };

  // Update item
  const toggleTodo = (id) => {
    setTodos(prev =>
      prev.map(todo =>
        todo.id === id ? { ...todo, done: !todo.done } : todo
      )
    );
  };

  // Replace item
  const updateText = (id, newText) => {
    setTodos(prev =>
      prev.map(todo =>
        todo.id === id ? { ...todo, text: newText } : todo
      )
    );
  };

  // Insert at position
  const insertAt = (index, todo) => {
    setTodos(prev => [
      ...prev.slice(0, index),
      todo,
      ...prev.slice(index),
    ]);
  };

  return (
    <div>
      {todos.map(todo => (
        <div key={todo.id}>
          <span style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>
            {todo.text}
          </span>
          <button onClick={() => toggleTodo(todo.id)}>Toggle</button>
          <button onClick={() => removeTodo(todo.id)}>Delete</button>
        </div>
      ))}
      <button onClick={() => addTodo('New todo')}>Add</button>
    </div>
  );
}

Array Operations Cheat Sheet

OperationMutating (WRONG)Immutable (CORRECT)
Addarr.push(item)[...arr, item]
Removearr.splice(i, 1)arr.filter(x => x.id !== id)
Replacearr[i] = newItemarr.map(x => x.id === id ? newItem : x)
Sortarr.sort()[...arr].sort()
Reversearr.reverse()[...arr].reverse()
Insertarr.splice(i, 0, item)[...arr.slice(0,i), item, ...arr.slice(i)]

Don't Mutate State Directly

This is the most common useState mistake. Let's understand why mutation breaks React.

Why Mutation Fails

function BrokenCounter() {
  const [user, setUser] = useState({ name: 'Alice', clicks: 0 });

  const handleClick = () => {
    user.clicks += 1;    // Mutates the existing object
    setUser(user);       // Same reference! React sees no change
    // React skips re-render because Object.is(oldState, newState) === true
  };

  return (
    <div>
      <p>{user.name}: {user.clicks} clicks</p>
      <button onClick={handleClick}>Click</button>
    </div>
  );
}
Why mutation fails:

  const obj = { clicks: 0 }
  useState(obj)           // React stores reference to obj
  obj.clicks = 1          // Mutate the same object
  setUser(obj)            // Pass same reference
  Object.is(obj, obj)     // true โ€” React thinks nothing changed
                          // No re-render!

Why immutable update works:

  const obj = { clicks: 0 }
  useState(obj)                    // React stores reference to obj
  const newObj = { ...obj, clicks: 1 }  // New object
  setUser(newObj)                  // Pass new reference
  Object.is(obj, newObj)           // false โ€” React sees a change
                                   // Re-render!

Fixed Version

function WorkingCounter() {
  const [user, setUser] = useState({ name: 'Alice', clicks: 0 });

  const handleClick = () => {
    setUser(prev => ({
      ...prev,
      clicks: prev.clicks + 1,  // New object with updated field
    }));
  };

  return (
    <div>
      <p>{user.name}: {user.clicks} clicks</p>
      <button onClick={handleClick}>Click</button>
    </div>
  );
}

Closure Over Stale State

This is the second most common mistake. It happens when a callback captures the state value from an old render.

The Problem

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

  const startTimer = () => {
    setInterval(() => {
      // BUG: count is always 0 (captured from the render where startTimer was called)
      setCount(count + 1);
    }, 1000);
  };

  return (
    <div>
      <p>{count}</p>
      <button onClick={startTimer}>Start</button>
    </div>
  );
}
Render 1: count = 0
  startTimer captures count = 0
  setInterval callback: setCount(0 + 1) = 1  (always)

Render 2: count = 1
  But the old setInterval still has count = 0 in its closure
  setCount(0 + 1) = 1  <-- stuck at 1!

The Fix: Functional Updates

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

  const startTimer = () => {
    setInterval(() => {
      // CORRECT: functional update always receives latest state
      setCount(prev => prev + 1);
    }, 1000);
  };

  return (
    <div>
      <p>{count}</p>
      <button onClick={startTimer}>Start</button>
    </div>
  );
}

Another Common Stale Closure

function SearchResults() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  const handleSearch = () => {
    // BUG if called from setTimeout or event listener:
    // query may be stale
    fetch(`/api/search?q=${query}`)
      .then(res => res.json())
      .then(setResults);
  };

  // This is fine in an event handler (query is current at click time)
  // But would be stale if passed to setInterval or addEventListener
  return (
    <div>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <button onClick={handleSearch}>Search</button>
    </div>
  );
}

Common Patterns and Recipes

Toggle Pattern

function Toggle() {
  const [isOn, setIsOn] = useState(false);

  return (
    <button onClick={() => setIsOn(prev => !prev)}>
      {isOn ? 'ON' : 'OFF'}
    </button>
  );
}

Previous Value Pattern

function CounterWithPrevious() {
  const [count, setCount] = useState(0);
  const [prevCount, setPrevCount] = useState(null);

  const increment = () => {
    setPrevCount(count);
    setCount(c => c + 1);
  };

  return (
    <div>
      <p>Current: {count}</p>
      <p>Previous: {prevCount ?? 'none'}</p>
      <button onClick={increment}>+1</button>
    </div>
  );
}

Reset Pattern

function ResettableForm() {
  const initialState = { name: '', email: '', message: '' };
  const [form, setForm] = useState(initialState);

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

  const reset = () => setForm(initialState);

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log(form);
    reset();
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={form.name}
        onChange={e => updateField('name', e.target.value)}
        placeholder="Name"
      />
      <input
        value={form.email}
        onChange={e => updateField('email', e.target.value)}
        placeholder="Email"
      />
      <textarea
        value={form.message}
        onChange={e => updateField('message', e.target.value)}
        placeholder="Message"
      />
      <button type="submit">Send</button>
      <button type="button" onClick={reset}>Reset</button>
    </form>
  );
}

Undo Pattern

function UndoableCounter() {
  const [history, setHistory] = useState([0]);
  const [index, setIndex] = useState(0);

  const current = history[index];

  const set = (value) => {
    const newHistory = history.slice(0, index + 1);
    newHistory.push(value);
    setHistory(newHistory);
    setIndex(newHistory.length - 1);
  };

  const undo = () => {
    if (index > 0) setIndex(i => i - 1);
  };

  const redo = () => {
    if (index < history.length - 1) setIndex(i => i + 1);
  };

  return (
    <div>
      <p>Value: {current}</p>
      <button onClick={() => set(current + 1)}>+1</button>
      <button onClick={() => set(current - 1)}>-1</button>
      <button onClick={undo} disabled={index === 0}>Undo</button>
      <button onClick={redo} disabled={index === history.length - 1}>Redo</button>
      <p>History: [{history.join(', ')}] (position {index})</p>
    </div>
  );
}

Custom Hooks with useState

Custom hooks let you extract reusable stateful logic.

useToggle

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

  const toggle = () => setValue(prev => !prev);
  const setTrue = () => setValue(true);
  const setFalse = () => setValue(false);

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

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

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

useCounter

function useCounter(initialValue = 0, { min = -Infinity, max = Infinity } = {}) {
  const [count, setCount] = useState(initialValue);

  const increment = () => setCount(c => Math.min(c + 1, max));
  const decrement = () => setCount(c => Math.max(c - 1, min));
  const reset = () => setCount(initialValue);
  const set = (value) => setCount(Math.min(Math.max(value, min), max));

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

// Usage
function QuantitySelector() {
  const { count, increment, decrement, reset } = useCounter(1, { min: 1, max: 99 });

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

useLocalStorage

function useLocalStorage(key, initialValue) {
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = localStorage.getItem(key);
      return item !== null ? JSON.parse(item) : initialValue;
    } catch {
      return initialValue;
    }
  });

  const setValue = (value) => {
    const valueToStore = value instanceof Function ? value(storedValue) : value;
    setStoredValue(valueToStore);
    try {
      localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      console.error('Failed to save to localStorage:', error);
    }
  };

  return [storedValue, setValue];
}

// Usage
function Settings() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');
  const [fontSize, setFontSize] = useLocalStorage('fontSize', 16);

  return (
    <div>
      <select value={theme} onChange={e => setTheme(e.target.value)}>
        <option value="light">Light</option>
        <option value="dark">Dark</option>
      </select>
      <input
        type="range"
        min={12}
        max={24}
        value={fontSize}
        onChange={e => setFontSize(Number(e.target.value))}
      />
    </div>
  );
}

useState Under the Hood

Understanding how React stores state helps you avoid bugs.

The Rules of Hooks

  1. Only call hooks at the top level โ€” never inside loops, conditions, or nested functions
  2. Only call hooks from React functions โ€” components or custom hooks
// WRONG โ€” conditional hook call
function Bad({ showName }) {
  if (showName) {
    const [name, setName] = useState('');  // React can't track this reliably
  }
  const [age, setAge] = useState(0);
}

// RIGHT โ€” always call, conditionally use
function Good({ showName }) {
  const [name, setName] = useState('');
  const [age, setAge] = useState(0);

  return (
    <div>
      {showName && <input value={name} onChange={e => setName(e.target.value)} />}
      <input value={age} onChange={e => setAge(Number(e.target.value))} />
    </div>
  );
}

How React Tracks State

React uses the call order of hooks to associate each useState with its stored value. This is why hooks must be called in the same order on every render.

Render 1:
  useState('')   -> hook[0] = ''       (name)
  useState(0)    -> hook[1] = 0        (age)

Render 2:
  useState('')   -> hook[0] = 'Alice'  (name โ€” was updated)
  useState(0)    -> hook[1] = 25       (age โ€” was updated)

If a conditional skipped the first hook:
  useState(0)    -> hook[0] = 'Alice'  // BUG! age gets name's value

Comparing State Management Approaches

ApproachComplexityBest For
useState (primitive)LowSingle values: toggles, counts, strings
useState (object)MediumRelated fields: form data, coordinates
useReducerMedium-HighComplex transitions: multi-step forms, state machines
Context + useStateMediumShared state across distant components
Context + useReducerHighApp-wide state with complex logic
External library (Zustand, Redux)HighLarge apps, dev tools, middleware

Debugging useState

React DevTools

Open React DevTools in your browser, select a component, and you'll see its state listed as State with each useState value numbered.

Logging State Changes

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

  const setCountWithLog = (valueOrFn) => {
    setCount(prev => {
      const next = typeof valueOrFn === 'function' ? valueOrFn(prev) : valueOrFn;
      console.log('count:', prev, '->', next);
      return next;
    });
  };

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

Common Debugging Checklist

Symptom: UI not updating after setState
  [ ] Are you mutating state instead of creating new object/array?
  [ ] Are you passing the same reference? (Object.is check)
  [ ] Is the state in the wrong component?

Symptom: State stuck on old value
  [ ] Stale closure? Use functional update
  [ ] Missing dependency in useEffect?
  [ ] Reading state outside of render/effect?

Symptom: Infinite re-renders
  [ ] Calling setState in render body? (Move to event handler or useEffect)
  [ ] useEffect without proper dependency array?
  [ ] Object/array in dependency array recreated every render?

Symptom: State resets unexpectedly
  [ ] Component unmounting/remounting? (Check key prop or conditional rendering)
  [ ] Parent re-creating the child with a new key?

Key Takeaways

  1. useState returns [value, setter] โ€” array destructuring lets you name them anything
  2. Use lazy initialization for expensive computations โ€” pass a function, not a value
  3. Never mutate state โ€” always create new objects/arrays
  4. Use functional updates when new state depends on previous state
  5. State updates are batched โ€” multiple setters in one handler = one re-render
  6. Hooks must be called in the same order โ€” no conditionals, loops, or early returns before hooks
  7. Closures can capture stale state โ€” functional updates fix this
  8. Extract custom hooks when stateful logic is reused across components
  9. Prefer multiple useState for independent values, single object for related values
  10. Consider useReducer when state logic gets complex (more than 3-4 related state variables with intertwined update logic)

Found this helpful?

Support devsofus โ€” help us keep creating free dev guides.

Related Articles