useState: What, When, and Why

State is the heart of interactive React applications. Without state, your components would be static - unable to respond to user input, update based on network responses, or change over time. The useState hook is React's primary mechanism for adding state to function components.

18 min read26 Jan 2026
useState: What, When, and Why

This article provides a comprehensive examination of useState: what it actually does at a technical level, when you should (and shouldn't) use it, and the reasoning behind its design decisions.


What is useState?

useState is a React hook that lets you add a state variable to your component. When you call useState, you're telling React: "I need to remember something between renders."

The Syntax

const [state, setState] = useState(initialValue);

This line does three things:

  1. Creates a state variable called state (you decide the name)
  2. Creates an updater function called setState (you decide the name)
  3. Sets the initial value to whatever you pass as the argument

What React Actually Does

When you call useState(0), React doesn't just store a number. It creates an entry in an internal data structure associated with your component instance. This entry contains:

  • The current value of the state
  • A reference to the component that owns it
  • A position index (because React identifies hooks by the order they're called)

When you call the setter function, React:

  1. Schedules a re-render of the component
  2. Updates the stored value
  3. Returns the new value on the next render

This is why state updates don't appear immediately:

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

  function handleClick() {
    setCount(count + 1);
    console.log(count); // Still 0! The update hasn't happened yet.
  }

  return <button onClick={handleClick}>Count: {count}</button>;
}

The console.log shows 0 because React hasn't re-rendered the component yet. The new value (1) only exists after the next render.


How useState Preserves State Across Renders

Every time your component renders, your function runs from top to bottom. Variables declared with let or const are created fresh each render:

function MyComponent() {
  let normalVariable = 0; // Reset to 0 on every render
  const [stateVariable, setStateVariable] = useState(0); // Preserved between renders

  // ...
}

React preserves state by:

  1. Maintaining a list of hooks per component instance - Each component has its own hook state, separate from other instances of the same component.

  2. Using call order as an identifier - React doesn't know what you named your state variables. It identifies hooks by the order they're called. This is why hooks must always be called in the same order (no conditionals, no loops).

  3. Associating state with position in the component tree - If you render <Counter /> twice, each has its own independent state because they're at different positions in the tree.

Demonstration of Hook Order Dependency

// ❌ WRONG: Conditional hook call
function BadComponent({ showName }) {
  if (showName) {
    const [name, setName] = useState('Alice'); // Hook call position changes!
  }
  const [age, setAge] = useState(25);
}

// ✅ CORRECT: Hooks always called in same order
function GoodComponent({ showName }) {
  const [name, setName] = useState('Alice'); // Always first
  const [age, setAge] = useState(25); // Always second

  if (!showName) {
    // Just don't render the name, don't skip the hook
  }
}

React relies on hook order to know which state belongs to which variable. If you call hooks conditionally, the order can change between renders, and React will assign the wrong state to the wrong variable.


The Functional Updater Pattern

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

// ❌ Can cause bugs with stale state
setCount(count + 1);

// ✅ Always uses the latest state
setCount((prevCount) => prevCount + 1);

Why the Functional Form Matters

Consider this code:

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

  function handleClick() {
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
  }

  return <button onClick={handleClick}>{count}</button>;
}

After clicking, you might expect count to be 3. It's actually 1.

Here's why: When handleClick runs, count is 0. All three setCount(count + 1) calls are actually setCount(0 + 1). React batches these updates and applies only the final one: setCount(1).

The functional form fixes this:

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

  function handleClick() {
    setCount((c) => c + 1); // 0 → 1
    setCount((c) => c + 1); // 1 → 2
    setCount((c) => c + 1); // 2 → 3
  }

  return <button onClick={handleClick}>{count}</button>;
}

Each updater function receives the latest pending state, not the state from the current render. After clicking, count is 3.

When to Use Each Form

Use the direct form when the new value doesn't depend on the previous value:

setName('Alice');
setIsOpen(true);
setSelectedId(event.target.id);

Use the functional form when the new value is derived from the previous value:

setCount((c) => c + 1);
setItems((prev) => [...prev, newItem]);
setUser((prev) => ({ ...prev, name: newName }));

Lazy Initialization

If your initial state requires an expensive computation, passing it directly causes that computation to run on every render:

// ❌ computeInitialItems() runs on EVERY render
const [items, setItems] = useState(computeInitialItems());

Even though React only uses the initial value on the first render, computeInitialItems() still runs every time. This is because function arguments are evaluated before the function is called.

The solution is lazy initialization:

// ✅ computeInitialItems only runs once, on first render
const [items, setItems] = useState(() => computeInitialItems());

When you pass a function to useState, React calls it only on the first render to get the initial value. On subsequent renders, React ignores this function entirely.

Practical Example

function FileEditor({ file }) {
  // ❌ Parses the file on every render
  const [content, setContent] = useState(parseFileContent(file));

  // ✅ Only parses once, when component mounts
  const [content, setContent] = useState(() => parseFileContent(file));

  // ...
}

The second form is more performant if parseFileContent is expensive.


When to Use useState

Use useState For:

1. User Input

Forms, text fields, checkboxes, sliders - anything the user directly controls:

function LoginForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  return (
    <form>
      <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
      <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
    </form>
  );
}

2. UI State

Modal open/closed, accordion expanded/collapsed, tab selection:

function Tabs({ tabs }) {
  const [activeTab, setActiveTab] = useState(0);

  return (
    <div>
      <div className="tab-headers">
        {tabs.map((tab, index) => (
          <button
            key={index}
            onClick={() => setActiveTab(index)}
            className={activeTab === index ? 'active' : ''}
          >
            {tab.title}
          </button>
        ))}
      </div>
      <div className="tab-content">{tabs[activeTab].content}</div>
    </div>
  );
}

3. Local Component Data

Data that belongs to a single component and doesn't need to be shared:

function Timer() {
  const [seconds, setSeconds] = useState(0);
  const [isRunning, setIsRunning] = useState(false);

  useEffect(() => {
    if (!isRunning) return;

    const interval = setInterval(() => {
      setSeconds((s) => s + 1);
    }, 1000);

    return () => clearInterval(interval);
  }, [isRunning]);

  return (
    <div>
      <p>{seconds} seconds</p>
      <button onClick={() => setIsRunning(!isRunning)}>{isRunning ? 'Pause' : 'Start'}</button>
    </div>
  );
}

4. Derived Loading/Error States

When making API calls within a component:

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

  useEffect(() => {
    setLoading(true);
    setError(null);

    fetchUser(userId)
      .then((data) => {
        setUser(data);
        setLoading(false);
      })
      .catch((err) => {
        setError(err.message);
        setLoading(false);
      });
  }, [userId]);

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

When NOT to Use useState

Don't Use useState For:

1. Values That Can Be Computed From Existing State or Props

If a value can be derived, compute it during render:

// ❌ Unnecessary state
function Cart({ items }) {
  const [total, setTotal] = useState(0);

  useEffect(() => {
    setTotal(items.reduce((sum, item) => sum + item.price, 0));
  }, [items]);

  return <p>Total: ${total}</p>;
}

// ✅ Computed during render
function Cart({ items }) {
  const total = items.reduce((sum, item) => sum + item.price, 0);

  return <p>Total: ${total}</p>;
}

The first version is worse because:

  • It causes an extra render (state update triggers re-render)
  • It's more code
  • The total can briefly be out of sync with items

2. Values That Don't Affect the UI

If changing a value shouldn't trigger a re-render, don't use state:

// ❌ Causes unnecessary re-renders
function VideoPlayer({ src }) {
  const [currentTime, setCurrentTime] = useState(0);

  // This triggers a re-render 60 times per second!
  function handleTimeUpdate(event) {
    setCurrentTime(event.target.currentTime);
  }

  return <video src={src} onTimeUpdate={handleTimeUpdate} />;
}

// ✅ Use a ref for values that don't need to trigger re-renders
function VideoPlayer({ src }) {
  const currentTimeRef = useRef(0);

  function handleTimeUpdate(event) {
    currentTimeRef.current = event.target.currentTime;
  }

  return <video src={src} onTimeUpdate={handleTimeUpdate} />;
}

3. Data That Needs to Be Shared Across Many Components

If multiple unrelated components need the same data, useState at the individual component level leads to prop drilling:

// ❌ Passing user through many layers
function App() {
  const [user, setUser] = useState(null);
  return (
    <Layout user={user}>
      <Page user={user} />
    </Layout>
  );
}

// ✅ Use Context for widely-shared state
const UserContext = createContext(null);

function App() {
  const [user, setUser] = useState(null);
  return (
    <UserContext.Provider value={user}>
      <Layout>
        <Page />
      </Layout>
    </UserContext.Provider>
  );
}

function DeepNestedComponent() {
  const user = useContext(UserContext);
  return <p>Hello, {user?.name}</p>;
}

4. Complex State With Many Related Values

When state has multiple related fields that change together, useReducer is often clearer:

// ❌ Multiple related states
function Form() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [errors, setErrors] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [isSubmitted, setIsSubmitted] = useState(false);

  // Easy to get these out of sync
}

// ✅ useReducer for complex state
function Form() {
  const [state, dispatch] = useReducer(formReducer, {
    name: '',
    email: '',
    errors: {},
    isSubmitting: false,
    isSubmitted: false,
  });

  // All state transitions are explicit in the reducer
}

Why useState Works This Way

Why Does setState Not Update Immediately?

React batches state updates for performance. If every setState call immediately re-rendered the component, you'd get unnecessary intermediate renders:

function handleClick() {
  setName('Alice'); // Without batching: render
  setAge(25); // Without batching: render again
  setCity('NYC'); // Without batching: render a third time
}

With batching, React waits until all event handler code has finished, then does a single re-render with all the new values. This is faster and avoids showing inconsistent intermediate states.

Why Is Initial Value Ignored After First Render?

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

On the first render, React creates a new state slot and initializes it with 0. On subsequent renders, React already has a value in that slot, so it ignores the 0 you passed and returns the current value instead.

This is intentional. If React re-initialized state every render, you couldn't preserve state. The initial value is only meaningful once: when the state is created.

Why Can't Hooks Be Conditional?

React doesn't store hook state by name. It stores it by position (first hook call, second hook call, etc.). If you skip a hook call on some renders:

// Render 1: showName is true
useState('Alice'); // Position 0 → 'Alice'
useState(25); // Position 1 → 25

// Render 2: showName is false, skip first useState
useState(25); // Position 0 → But React thinks this is 'Alice'!

React has no way to know you skipped a hook. It just sees "hook at position 0" and gives you the wrong state. This is why the Rules of Hooks exist.


State vs. Refs

Both useState and useRef persist values across renders. The key difference:

AspectuseStateuseRef
Triggers re-renderYesNo
Updates synchronouslyNo (batched)Yes
Accessvalue, setValueref.current
Use caseUI-affecting dataDOM refs, timers, previous values
function Example() {
  const [count, setCount] = useState(0); // Changing this re-renders
  const renderCount = useRef(0); // Changing this doesn't re-render

  renderCount.current += 1;

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

Use useState when the value affects what React renders. Use useRef when you need to remember something without triggering a re-render.


Common Patterns

Toggle Pattern

const [isOpen, setIsOpen] = useState(false);
const toggle = () => setIsOpen((prev) => !prev);

Object State Pattern

const [user, setUser] = useState({ name: '', email: '' });

// Update one field
const setName = (name) => setUser((prev) => ({ ...prev, name }));

// Update multiple fields
const updateUser = (updates) => setUser((prev) => ({ ...prev, ...updates }));

Array State Pattern

const [items, setItems] = useState([]);

// Add item
const addItem = (item) => setItems((prev) => [...prev, item]);

// Remove item
const removeItem = (id) => setItems((prev) => prev.filter((item) => item.id !== id));

// Update item
const updateItem = (id, updates) =>
  setItems((prev) => prev.map((item) => (item.id === id ? { ...item, ...updates } : item)));

Previous Value Pattern

Sometimes you need the previous value for comparison:

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

  useEffect(() => {
    prevCountRef.current = count;
  });

  const prevCount = prevCountRef.current;

  return (
    <div>
      <p>
        Now: {count}, Before: {prevCount}
      </p>
      <button onClick={() => setCount((c) => c + 1)}>Increment</button>
    </div>
  );
}

Debugging useState

State Not Updating?

  1. Check if you're reading stale state inside a closure:

    // ❌ count is stale inside the callback
    useEffect(() => {
      const id = setInterval(() => console.log(count), 1000);
      return () => clearInterval(id);
    }, []); // Empty deps means count is always the initial value
    
  2. Check if you're mutating state instead of replacing it:

    // ❌ Mutating state directly
    items.push(newItem);
    setItems(items); // Same reference, React doesn't see a change
    
    // ✅ Create new array
    setItems([...items, newItem]);
    

Too Many Re-renders?

  1. Check for state updates during render:

    function Bad() {
      const [count, setCount] = useState(0);
      setCount(1); // ❌ Infinite loop!
      return <div>{count}</div>;
    }
    
  2. Check for missing dependencies causing effect loops:

    useEffect(() => {
      setData(transform(props.value)); // Updates state
    }, [props.value, transform]); // Make sure transform is stable
    

Summary

useState is foundational to React development. Understanding its mechanics - how it preserves state, why updates are batched, when to use functional updates - prevents bugs and helps you write more efficient components.

Key takeaways:

  1. State is preserved between renders by hook call order, not variable names
  2. Use the functional updater setState(prev => ...) when new state depends on previous state
  3. Use lazy initialization useState(() => expensive()) for expensive initial values
  4. Don't use state for derived values or values that don't affect the UI
  5. State updates are batched and asynchronous

The more you understand about how React's hooks work internally, the more predictable your code becomes. useState looks simple, but mastering it is essential for building robust React applications.