Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

Props vs State

State & useState Hook

State is what makes React components interactive. It’s data that changes over time and triggers re-renders when updated. Think of state as a component’s memory. Just like you remember whether a light switch is on or off, a component remembers its own data — the current count, whether a dropdown is open, the text a user has typed so far. When that memory changes, React re-renders the component so the screen stays in sync with the data. By contrast, props are like arguments passed to a function — they come from the outside and the component cannot change them. State is owned by the component; props are given to the component.

State vs Props

Understanding the difference is crucial:
PropsState
Passed from parentManaged within component
Read-only (immutable)Can be updated
Received as function parametersCreated with useState
Changes come from parentChanges come from component itself
┌─────────────────────────────────────────────────────────────────┐
│                        Parent Component                         │
│                                                                 │
│                    ┌─────────────────────┐                     │
│                    │  state = { count }  │                     │
│                    └─────────────────────┘                     │
│                              │                                  │
│                    passes as prop                               │
│                              ▼                                  │
│              ┌───────────────────────────────┐                 │
│              │      Child Component          │                 │
│              │   props = { count: 5 }        │                 │
│              │   (read-only)                 │                 │
│              └───────────────────────────────┘                 │
└─────────────────────────────────────────────────────────────────┘

The useState Hook

Hooks are functions that let you “hook into” React features. useState is the fundamental hook for managing state.

Basic Syntax

import { useState } from 'react';

const [stateValue, setStateFunction] = useState(initialValue);
  • stateValue: Current value of the state
  • setStateFunction: Function to update the state
  • initialValue: Starting value (any type: string, number, boolean, object, array)

Simple Counter Example

import { useState } from 'react';

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

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

Toggle Example

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

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

Rules of useState

1. Never Modify State Directly

React won’t know the state changed and won’t re-render.
// ❌ WRONG - Direct mutation
count = count + 1;

// ✅ CORRECT - Use the setter function
setCount(count + 1);

2. Call Hooks at the Top Level

Don’t call hooks inside loops, conditions, or nested functions.
function BadComponent() {
  // ❌ WRONG - Hook inside condition
  if (someCondition) {
    const [value, setValue] = useState(0);
  }
}

function GoodComponent() {
  // ✅ CORRECT - Hook at top level
  const [value, setValue] = useState(0);
  
  // Use conditions with the state value, not the hook
  if (someCondition) {
    // do something with value
  }
}

3. Only Call Hooks in React Functions

Hooks only work in React function components or custom hooks.

Functional Updates (Previous State)

When your new state depends on the previous state, use the functional update form:
// ❌ Can cause bugs with multiple rapid updates
setCount(count + 1);
setCount(count + 1); // Both use the same stale 'count' value!

// ✅ CORRECT - Uses the latest state
setCount(prevCount => prevCount + 1);
setCount(prevCount => prevCount + 1); // Each gets the truly updated value
Why does this matter? (The Stale Closure Problem) React batches state updates for performance. When you click a button that triggers multiple setCount calls, they all run in the same render cycle, so they all read the same “snapshot” of count. This is called a stale closure — the function closes over an old value of the variable.The functional form setCount(prev => prev + 1) avoids this because React passes the latest state value to your updater function, not the stale one captured in the closure.This is one of the most common sources of bugs in React. If your state update depends on the current state, always use the functional form.

Practical Example

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

  const handleClick = () => {
    // All three will use functional updates for correct behavior
    setCount(prev => prev + 1);
    setCount(prev => prev + 1);
    setCount(prev => prev + 1);
    // Result: count increments by 3
  };

  return <button onClick={handleClick}>Add 3 (count: {count})</button>;
}

Lazy Initialization

If your initial state requires expensive computation, pass a function:
// ❌ Expensive function runs on EVERY render
const [data, setData] = useState(expensiveComputation());

// ✅ Function only runs on initial render
const [data, setData] = useState(() => expensiveComputation());

Real-World Example

function TodoApp() {
  // Load todos from localStorage only on first render
  const [todos, setTodos] = useState(() => {
    const saved = localStorage.getItem('todos');
    return saved ? JSON.parse(saved) : [];
  });

  // ...
}

State with Objects

When state is an object, you must spread the previous state to avoid losing properties.
function UserForm() {
  const [user, setUser] = useState({
    name: '',
    email: '',
    age: 0
  });

  const updateName = (newName) => {
    // ❌ WRONG - loses email and age
    setUser({ name: newName });
    
    // ✅ CORRECT - spread previous state
    setUser({ ...user, name: newName });
    
    // ✅ ALSO CORRECT - functional update
    setUser(prev => ({ ...prev, name: newName }));
  };

  return (
    <form>
      <input
        value={user.name}
        onChange={(e) => setUser({ ...user, name: e.target.value })}
        placeholder="Name"
      />
      <input
        value={user.email}
        onChange={(e) => setUser({ ...user, email: e.target.value })}
        placeholder="Email"
      />
    </form>
  );
}

Nested Objects

For deeply nested objects, consider flattening your state or using a library like Immer.
const [user, setUser] = useState({
  name: 'Alice',
  address: {
    city: 'NYC',
    zip: '10001'
  }
});

// Updating nested property
setUser(prev => ({
  ...prev,
  address: {
    ...prev.address,
    city: 'LA'
  }
}));

State with Arrays

Arrays require special handling because they’re reference types.
function TodoList() {
  const [todos, setTodos] = useState([
    { id: 1, text: 'Learn React', done: false },
    { id: 2, text: 'Build project', done: false }
  ]);

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

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

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

  // ✅ Insert at specific position
  const insertAt = (index, newTodo) => {
    setTodos([
      ...todos.slice(0, index),
      newTodo,
      ...todos.slice(index)
    ]);
  };

  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>
          <input
            type="checkbox"
            checked={todo.done}
            onChange={() => toggleTodo(todo.id)}
          />
          <span style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>
            {todo.text}
          </span>
          <button onClick={() => removeTodo(todo.id)}>Delete</button>
        </li>
      ))}
    </ul>
  );
}
Never mutate arrays directly! Methods like push, pop, splice, sort, and reverse mutate the original array.
// ❌ WRONG - mutates original array
todos.push(newTodo);
setTodos(todos);

// ✅ CORRECT - creates new array
setTodos([...todos, newTodo]);

Array Operation Cheat Sheet

OperationMutating (Don’t Use)Non-Mutating (Use This)
Addpush, unshift[...arr, item], [item, ...arr]
Removepop, shift, splicefilter()
Replacearr[i] = x, splicemap()
Sortsort()[...arr].sort()
Reversereverse()[...arr].reverse()

Multiple State Variables

You can (and often should) use multiple useState calls:
function UserProfile() {
  // Separate concerns into individual state pieces
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [isSubscribed, setIsSubscribed] = useState(false);
  const [notifications, setNotifications] = useState([]);
  
  // Each can be updated independently
}

When to Use Object State vs Multiple States

// ✅ Multiple states - when values change independently
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');

// ✅ Object state - when values always change together
const [position, setPosition] = useState({ x: 0, y: 0 });

// ✅ Object state - for forms with many fields
const [formData, setFormData] = useState({
  username: '',
  email: '',
  password: '',
  confirmPassword: ''
});

State Updates and Re-rendering

When state changes, React:
  1. Schedules a re-render
  2. Calls your component function again
  3. Compares the new JSX with the previous
  4. Updates only what changed in the DOM
function RenderDemo() {
  const [count, setCount] = useState(0);
  
  console.log('Component rendered!'); // Logs on every render

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}
State updates are asynchronous. The new value isn’t immediately available after calling the setter.
const [count, setCount] = useState(0);

const handleClick = () => {
  setCount(count + 1);
  console.log(count); // Still logs 0, not 1!
};

🎯 Practice Exercises

Build a product card with quantity controls:
function ProductCard({ name, price }) {
  const [quantity, setQuantity] = useState(0);

  const increment = () => setQuantity(prev => prev + 1);
  const decrement = () => setQuantity(prev => Math.max(0, prev - 1));

  return (
    <div className="product-card">
      <h3>{name}</h3>
      <p>${price}</p>
      <div className="quantity-controls">
        <button onClick={decrement} disabled={quantity === 0}></button>
        <span>{quantity}</span>
        <button onClick={increment}>+</button>
      </div>
      <p>Total: ${(price * quantity).toFixed(2)}</p>
    </div>
  );
}
Build an FAQ item that expands/collapses:
function FAQItem({ question, answer }) {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div className="faq-item">
      <button 
        onClick={() => setIsOpen(!isOpen)}
        className="faq-question"
      >
        {question}
        <span>{isOpen ? '−' : '+'}</span>
      </button>
      {isOpen && (
        <div className="faq-answer">
          {answer}
        </div>
      )}
    </div>
  );
}

// Usage
<FAQItem 
  question="What is React?" 
  answer="React is a JavaScript library for building user interfaces."
/>
Build a form that tracks data across multiple steps:
function MultiStepForm() {
  const [step, setStep] = useState(1);
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    plan: 'basic'
  });

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

  const nextStep = () => setStep(prev => Math.min(prev + 1, 3));
  const prevStep = () => setStep(prev => Math.max(prev - 1, 1));

  return (
    <div className="multi-step-form">
      <div className="progress">Step {step} of 3</div>
      
      {step === 1 && (
        <div>
          <h2>Personal Info</h2>
          <input
            value={formData.name}
            onChange={(e) => updateField('name', e.target.value)}
            placeholder="Your name"
          />
        </div>
      )}
      
      {step === 2 && (
        <div>
          <h2>Contact</h2>
          <input
            type="email"
            value={formData.email}
            onChange={(e) => updateField('email', e.target.value)}
            placeholder="Your email"
          />
        </div>
      )}
      
      {step === 3 && (
        <div>
          <h2>Review</h2>
          <p>Name: {formData.name}</p>
          <p>Email: {formData.email}</p>
          <p>Plan: {formData.plan}</p>
        </div>
      )}
      
      <div className="buttons">
        {step > 1 && <button onClick={prevStep}>Back</button>}
        {step < 3 ? (
          <button onClick={nextStep}>Next</button>
        ) : (
          <button onClick={() => alert('Submitted!')}>Submit</button>
        )}
      </div>
    </div>
  );
}

Summary

ConceptDescription
useStateHook to add state to functional components
Setter FunctionOnly way to update state and trigger re-render
Functional UpdatessetState(prev => newValue) for updates based on previous state
Lazy InitializationuseState(() => expensiveCalc()) for expensive initial values
ImmutabilityNever mutate state directly; always create new values
ObjectsSpread previous state: { ...prev, newProp }
ArraysUse map, filter, spread; never push, pop, splice
BatchingReact batches multiple state updates for performance

Next Steps

In the next chapter, you’ll learn about Handling Events — responding to user clicks, form submissions, and keyboard input!

Interview Deep-Dive

Strong Answer: State batching means React groups multiple setState calls into a single re-render instead of re-rendering after each one. Before React 18, batching only happened inside React event handlers. If you called setCount(1); setName('Alice') inside an onClick, React batched them into one render. But if those same calls happened inside a setTimeout, a Promise .then(), or a native event listener, each setState triggered a separate re-render — two renders instead of one.React 18 introduced automatic batching everywhere: event handlers, timeouts, promises, native event listeners, even queueMicrotask. This was a significant performance improvement for real-world apps. I worked on a dashboard that fetched data in a useEffect and then called three separate state setters (setData, setLoading, setError). Before React 18, that caused three re-renders in sequence. After upgrading, it became one render.The practical implication is that you should not expect state to be updated synchronously between consecutive setState calls. If you need to read the “latest” state after an update, use the functional updater: setState(prev => prev + 1). If you genuinely need to force a synchronous flush (rare), React 18 provides flushSync from react-dom, which opts out of batching for that specific update.Follow-up: What is the stale closure problem, and why does the functional updater pattern solve it? Give a concrete scenario.A stale closure happens when a function captures a variable from its surrounding scope, but by the time the function executes, that variable holds an old value. In React, this is extremely common with useEffect and event handlers.Concrete scenario: a counter component with a “increment 3 times” button that does setCount(count + 1) three times in a row. All three calls read the same count value from the closure (say, 0), so all three compute 0 + 1 = 1. The result is the count goes to 1, not 3.The functional updater setCount(prev => prev + 1) fixes this because React does not read from the closure — it passes the truly current state value as the prev argument to each updater function in sequence. First call: prev is 0, returns 1. Second call: prev is 1, returns 2. Third call: prev is 2, returns 3. The final state is 3.The deeper reason this works is that React queues updater functions internally and processes them in order during the next render, each receiving the output of the previous one. It is essentially a reduce operation over the queue of updaters.
Strong Answer: The key question is: do these values change together or independently?Multiple useState calls are better when values are independent. A form with a name field and a “terms accepted” checkbox — those change at different times for different reasons. Separate state makes each updater simpler, avoids unnecessary spreads, and makes it obvious which state a setter modifies.A single state object is better when values always change together. Mouse coordinates { x, y } are a good example — you never update x without y. A form with many fields that share a single handleChange handler is another: a single state object with computed property names [name]: value is cleaner than 15 separate useState calls.The tradeoff with object state: you must spread the previous state on every update ({ ...prev, name: newName }), which is verbose and error-prone. Forget the spread and you lose properties. For deeply nested state (like user.address.city), the spread nesting becomes painful: { ...state, address: { ...state.address, city: 'LA' } }. At that point, I reach for useReducer or a library like Immer.The tradeoff with many separate states: if you have 10+ useState calls, the component becomes cluttered and it is hard to see which states are related. This is a signal to either consolidate into an object, extract into a custom hook, or use useReducer.My rule of thumb: 1-4 independent values, use separate useState. 5+ related values or complex update logic, use useReducer. Object state with useState is the middle ground I use for forms with 3-8 fields.Follow-up: You mentioned Immer. How does Immer work, and why does Redux Toolkit use it by default?Immer uses JavaScript Proxies to create a “draft” copy of your state. You write code that looks like direct mutation (draft.user.name = 'Alice'), but Immer intercepts every property access and mutation through the Proxy, tracks what changed, and produces a new immutable object with structural sharing — only the changed parts are new objects; unchanged branches keep the same references.Redux Toolkit uses Immer in createSlice because reducer boilerplate was the number one complaint about Redux. Writing return { ...state, users: { ...state.users, [id]: { ...state.users[id], name: 'Alice' } } } for a nested update is painful and error-prone. With Immer, you write state.users[id].name = 'Alice' and get the same immutable result. The code is dramatically easier to read and less likely to have spread-related bugs.The tradeoff: Immer adds bundle size (around 6KB gzipped) and a small runtime overhead from Proxy interception. For most apps this is negligible. The one gotcha is that Immer-wrapped code looks mutable, so developers new to the codebase may not realize immutability is still being enforced. If they write the same “mutation” style outside of a createSlice reducer, they will create actual mutations and introduce bugs.
Strong Answer: Lazy initialization is passing a function to useState instead of a value: useState(() => expensiveComputation()) instead of useState(expensiveComputation()). The difference is that the function form only executes once on the initial render, while the value form executes on every render even though React discards the result after the first.This matters when the initial value requires expensive work. The most common production example I have used is reading from localStorage: useState(() => JSON.parse(localStorage.getItem('settings') || '{}')). Without lazy init, JSON.parse and the localStorage.getItem call happen on every re-render. For a component that re-renders frequently (typing in a search field, for example), that is wasted work. localStorage access is synchronous and hits disk I/O, and JSON.parse on a large settings object adds CPU overhead.Another case: generating initial data structures. If your initial state is a large Map or a sorted array, computing it on every render wastes resources. useState(() => buildInitialMap(rawData)) ensures it runs once.The gotcha: lazy init only helps with the initial computation. If you need to recompute state when a prop changes, lazy init does not help — you need useEffect or a key reset. Also, the initializer function receives no arguments, so you cannot pass parameters to it directly (use a closure instead).Follow-up: You have a component that reads from localStorage on mount. A user opens two tabs. How do you keep them in sync?localStorage does not automatically sync state across tabs within your React app. One tab writes, the other tab’s React state is stale until a full page refresh. The solution is the storage event, which fires on other tabs (not the originating tab) when localStorage changes.You set up a useEffect that listens for the storage event on the window: window.addEventListener('storage', handler). In the handler, you check event.key to see if it matches your key, and if so, update the React state with event.newValue. You also need cleanup in the useEffect return.The edge case: the storage event only fires on other tabs, not the one that wrote the value. If you also need to react to changes within the same tab (from a different component), you need a custom event or a shared Context/store. Libraries like usehooks-ts provide a useLocalStorage hook that handles cross-tab sync out of the box.
Strong Answer: React uses reference equality (triple equals) to determine whether state has changed. When you call setState, React compares the new value with the old value. For objects and arrays, this comparison checks whether they are the same reference in memory, not whether their contents are different.If you mutate an object and pass the same reference back — state.items.push(newItem); setState(state.items) — React sees the same reference and concludes nothing changed. It skips the re-render entirely. Your data is updated in memory, but the UI is stale. This is the most immediate and visible breakage.But there are deeper problems. React.memo relies on shallow prop comparison. If a memoized child receives the same object reference, it skips rendering even though the object’s internals changed. useMemo and useCallback dependency checks also use reference equality — stale dependencies mean stale memoized values.The React DevTools time-travel debugger relies on each state update producing a distinct snapshot. If you mutate in place, previous “snapshots” are also mutated because they all point to the same object. You lose the ability to inspect or replay past states.Concurrent features in React 18+ rely on being able to throw away a partial render and restart it. If reducers mutate state, a discarded render may have already altered the “real” state, causing corruption.In my experience, mutation bugs are insidious because they manifest inconsistently. They might work in development (where batching and timing differ) but fail in production. They might work 99 times and fail on the 100th due to a race between renders.Follow-up: How do you enforce immutability in a team codebase? What tools or patterns help?Three layers of defense. First, TypeScript with Readonly<T> and ReadonlyArray<T> types on state. This catches mutations at compile time. Second, ESLint with the eslint-plugin-react rule no-direct-mutation-state and the Immer-based patterns in Redux Toolkit that make “mutation” safe by design. Third, Object.freeze() in development (React already does this for props) — though manually freezing state is usually overkill.For team adoption, the most effective approach is establishing the pattern of always using the functional updater form (setState(prev => ({ ...prev, field: newValue }))) and reviewing PRs for .push(), .splice(), direct property assignment, and .sort() without a spread. A lint rule that forbids array mutation methods on state variables catches most issues automatically.