Skip to main content

🪝 useState Hook in Functional Components

The useState hook is the workhorse of modern React — the single call that gives a function component a piece of memory and a way to update it. Get comfortable with its four behaviors (return pair, re-render on change, functional updates, and immutable updates) and you unlock the majority of day-to-day React.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Call useState correctly and read/update a state variable
  • Store any data type in state — numbers, strings, booleans, objects, and arrays
  • Use functional updates to avoid stale-state bugs and handle batched updates
  • Update objects and arrays immutably with the spread pattern
  • Apply lazy initialization for expensive initial state
  • Follow the Rules of Hooks and avoid the most common pitfalls

Estimated Time: 35–45 minutes  •  Difficulty: Intermediate

Hands-on: Build a controlled sign-up form that manages several fields with useState.

In This Lesson

What the Hook Gives You

Before hooks arrived in React 16.8, only class components could hold state. Hooks changed that: useState lets a plain function component "remember" a value between renders and re-render itself when that value changes — with no class, no this, and far less ceremony.

💡 The hotel-safe analogy: Calling useState is like renting a small safe. You put something in (the initial value), you get a way to look at what's inside (the state variable), and you get a passcode to change the contents (the setter). Only your component can open this safe, and each component instance gets its own.

A hook is just a special function whose name starts with use that lets you "hook into" React features from a function component. useState is the most fundamental one — the foundation the others build on.

Anatomy of useState

You call useState with the initial value and it returns an array of exactly two things, which you destructure by convention into [value, setValue]:

import { useState } from 'react';

function Counter() {
  //      ┌ current value   ┌ setter function
  const [count, setCount] = useState(0); // 0 is the initial value

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  );
}
useState returns a value and a setter useState called with an initial value returns a two-element array: the current state value and a setter function that updates it. useState(0) initial value count current value (read) setCount setter (triggers re-render)
Figure 1 — useState hands back a read value and a write function. Calling the setter is the only sanctioned way to change state and ask React to re-render.

Behind the scenes: React stores the current value outside your function and hands it back on each render. When you call the setter, React saves the new value and schedules a re-render, during which useState returns the updated value.

Working with Different Data Types

State can hold anything a JavaScript variable can. Here it is with a string driving a controlled input — the input's value comes from state, and every keystroke updates state:

function Greeter() {
  const [name, setName] = useState('');

  return (
    <div>
      <input
        type="text"
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="Enter your name"
      />
      <p>Hello, {name || 'stranger'}!</p>
    </div>
  );
}

A boolean makes a natural toggle, and you can hold several independent pieces of state by calling useState more than once:

function LikeButton() {
  const [likes, setLikes] = useState(0);
  const [isLiked, setIsLiked] = useState(false);

  const handleLike = () => {
    setLikes(prev => (isLiked ? prev - 1 : prev + 1));
    setIsLiked(prev => !prev);
  };

  return (
    <button onClick={handleLike} style={{ color: isLiked ? 'crimson' : 'gray' }}>
      {isLiked ? '❤️' : '🤍'} {likes}
    </button>
  );
}

📖 Controlled component

A form element whose displayed value is driven by React state (via value + onChange) rather than by the DOM itself. State is the single source of truth, which makes validation and conditional logic straightforward.

Functional Updates & Batching

Here is the single most important useState subtlety. Within one render, the state variable is a fixed snapshot — it does not change value just because you called the setter. And React batches multiple updates in the same event handler into one re-render. Both facts bite in this classic bug:

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

  const addThree = () => {
    setCount(count + 1); // count is 0 here → schedules 1
    setCount(count + 1); // count is STILL 0 → schedules 1
    setCount(count + 1); // count is STILL 0 → schedules 1
  };
  // Result: count becomes 1, not 3
  return <button onClick={addThree}>Count: {count}</button>;
}

Because count is frozen at 0 for the whole handler, all three calls compute 0 + 1. The fix is the functional update form: pass the setter a function that receives the latest pending value and returns the next one.

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

  const addThree = () => {
    setCount(prev => prev + 1); // 0 → 1
    setCount(prev => prev + 1); // 1 → 2
    setCount(prev => prev + 1); // 2 → 3
  };
  // Result: count becomes 3 ✅
  return <button onClick={addThree}>Count: {count}</button>;
}

The same rule saves you inside timers and other async callbacks, where the captured count would otherwise be stale by the time the callback runs:

const delayedIncrement = () => {
  setTimeout(() => {
    // Uses whatever the latest value is, not the one captured 2s ago
    setCount(prev => prev + 1);
  }, 2000);
};

✅ Rule of thumb

If the next state depends on the previous state, use the functional form setX(prev => ...). If you're setting a brand-new value that doesn't depend on the old one (like setName(e.target.value)), the direct form is fine.

Updating Objects & Arrays

Unlike a class component's this.setState, the useState setter replaces the value — it does not merge. And you must treat state as immutable: never mutate the existing object or array, always create a new one. React decides whether to re-render by comparing references, so an in-place mutation is invisible to it.

⚠️ Replace, don't merge — and never mutate

const [user, setUser] = useState({ name: 'Ada', email: 'ada@x.com' });

// ❌ Wrong: this REPLACES the whole object, dropping `name`
setUser({ email: 'new@x.com' });

// ❌ Wrong: mutating in place — React sees the same reference, won't re-render
user.email = 'new@x.com';
setUser(user);

// ✅ Right: spread the old object, override one field
setUser(prev => ({ ...prev, email: 'new@x.com' }));

A single change handler can drive a whole object of form fields by using the input's name as a computed key:

function ProfileForm() {
  const [profile, setProfile] = useState({ name: '', email: '', bio: '' });

  const handleChange = (e) => {
    const { name, value } = e.target;
    setProfile(prev => ({ ...prev, [name]: value }));
  };

  return (
    <form>
      <input name="name"  value={profile.name}  onChange={handleChange} placeholder="Name" />
      <input name="email" value={profile.email} onChange={handleChange} placeholder="Email" />
      <textarea name="bio" value={profile.bio}  onChange={handleChange} placeholder="Bio" />
    </form>
  );
}

Arrays follow the same principle — reach for the non-mutating methods (map, filter, spread) rather than push, splice, or index assignment:

const [tasks, setTasks] = useState([]);

// Add
setTasks(prev => [...prev, newTask]);

// Remove by id
setTasks(prev => prev.filter(t => t.id !== id));

// Update one item
setTasks(prev =>
  prev.map(t => (t.id === id ? { ...t, done: !t.done } : t))
);

Lazy Initial State

The argument you pass to useState is only used on the first render, but if you pass a value produced by a function call, that function runs on every render (its result is just ignored after the first). When the computation is expensive — parsing localStorage, building a big structure — pass a function instead so React calls it only once.

// ❌ readAndParse() runs on every render, even though only the first matters
const [prefs, setPrefs] = useState(readAndParse());

// ✅ Lazy: the initializer function runs once, on the first render only
const [prefs, setPrefs] = useState(() => {
  const saved = localStorage.getItem('preferences');
  return saved ? JSON.parse(saved) : { theme: 'light', fontSize: 'medium' };
});

💡 When to bother

Only use lazy initialization when the initializer is genuinely costly or has side-effect-like reads (storage, expensive parsing). For a plain literal like useState(0) or useState(''), there is nothing to optimize.

The Rules of Hooks

React relies on hooks being called in the same order on every render, so it can match each useState call to the right stored value. Two rules keep that guarantee:

flowchart TD A[Calling a Hook] --> B{Top level of a
component or custom hook?} B -->|No: inside if / loop / nested fn| X[❌ Breaks Hook order] B -->|Yes| C{Called from a React
function component or hook?} C -->|No: regular JS function| X C -->|Yes| D[✅ Valid]
  1. Only call hooks at the top level. Never inside conditions, loops, or nested functions — the number and order of hook calls must be identical every render.
  2. Only call hooks from React functions. That means function components or your own custom hooks, not plain JavaScript functions.

⚠️ This breaks the rules

function Bad({ loggedIn }) {
  if (loggedIn) {
    const [name, setName] = useState(''); // ❌ conditional hook call
  }
  // If loggedIn flips between renders, hook order changes → React errors
}

Put the hook at the top and make the logic conditional instead.

Hands-on Exercise

🏋️ Build a Controlled Sign-Up Form

Objective: Manage several form fields with useState, derive validity, and gate submission on it.

Requirements:

  1. Fields for name, email, and password, all controlled.
  2. Store all three in a single object in state, updated by one change handler.
  3. Compute an isValid flag as a derived value (name non-empty, email contains @, password ≥ 8 chars).
  4. Disable the submit button while the form is invalid, and show the collected data on submit.
💡 Hint

Use one useState({ name: '', email: '', password: '' }) and the [name]: value computed-key trick for the handler. Don't store isValid in state — compute it right before the return so it can never fall out of sync.

✅ Solution
import { useState } from 'react';

function SignUpForm() {
  const [form, setForm] = useState({ name: '', email: '', password: '' });

  const handleChange = (e) => {
    const { name, value } = e.target;
    setForm(prev => ({ ...prev, [name]: value }));
  };

  // Derived — never stored in state
  const isValid =
    form.name.trim() !== '' &&
    form.email.includes('@') &&
    form.password.length >= 8;

  const handleSubmit = (e) => {
    e.preventDefault();
    if (!isValid) return;
    alert(`Welcome, ${form.name}!`);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" value={form.name} onChange={handleChange} placeholder="Name" />
      <input name="email" type="email" value={form.email} onChange={handleChange} placeholder="Email" />
      <input name="password" type="password" value={form.password} onChange={handleChange} placeholder="Password (8+ chars)" />
      <button type="submit" disabled={!isValid}>Sign up</button>
    </form>
  );
}

🎯 Quick Quiz

Question 1: Inside one click handler you call setCount(count + 1) three times. count starts at 0. What is the final value?

Question 2: You have const [user, setUser] = useState({ name: 'Ada', email: '' }) and want to change only the email. Which is correct?

Question 3: Why pass a function to useState, as in useState(() => expensiveRead())?

Common Pitfalls

⚠️ Watch out for these

  • Expecting state to update synchronously. Reading the state variable right after calling the setter still gives the old value — the new one appears on the next render.
  • Mutating objects/arrays then calling the setter with the same reference. React compares by identity; create a new object/array.
  • Forgetting the functional form when the next value depends on the previous one, especially in loops, timers, and rapid clicks.
  • Calling hooks conditionally. Keep every useState at the top level, unconditionally.
  • Storing derivable data in state instead of computing it during render.

💡 useState vs. useReducer

useState shines for independent, simple pieces of state. When several values change together through many related actions, useReducer (covered later) often reads more cleanly. Start with useState and graduate only when the logic asks for it.

Summary & Quiz

🎉 Key Takeaways

  • useState(initial) returns [value, setter]; calling the setter re-renders the component.
  • Within a render, the state value is a fixed snapshot, and React batches updates.
  • Use the functional form setX(prev => ...) whenever the next value depends on the previous one.
  • The setter replaces, never merges — update objects and arrays immutably with the spread pattern.
  • Use lazy initialization for costly initial state, and always obey the Rules of Hooks.

📚 Further Reading

🚀 What's Next?

State lets a component remember values, but real apps also need to talk to the outside world — fetch data, set timers, subscribe to events. Next up: Component Lifecycle and Effects, where the useEffect hook synchronizes your component with everything beyond React.

🎉 Hooked!

You can now give any component memory. Let's connect it to the outside world.