Introduction to State (useState)

Difficulty: Beginner

State is data that a component manages internally and can change over time. While props come from a parent and are read-only, state is owned by the component itself and can be updated. When state changes, React automatically re-renders the component to reflect the new data. This is what makes React interfaces interactive - user actions trigger state changes, which trigger re-renders, which update the UI.

The `useState` hook is the primary way to add state to a functional component. It takes an initial value as its argument and returns an array with exactly two elements: the current state value and a function to update it. By convention, these are destructured as `[value, setValue]`. For example, `const [count, setCount] = useState(0)` creates a state variable `count` starting at 0 and a function `setCount` to update it.

State updates in React are asynchronous and batched. When you call `setCount(5)`, the component does not re-render immediately. React batches multiple state updates together and processes them in a single re-render for performance. This means if you call `setCount(count + 1)` twice in a row, you will not get count + 2 because both calls see the same value of count. To handle this, use the functional update form: `setCount(prev => prev + 1)`, which always receives the latest state value.

The initial value you pass to useState is only used on the first render. On subsequent re-renders, React remembers the current state and ignores the initial value. If computing the initial value is expensive (e.g., reading from localStorage), you can pass a function: `useState(() => JSON.parse(localStorage.getItem('data')))`. This lazy initialization function only runs once on mount.

Understanding the difference between props and state is crucial. Props are passed in from outside and the component cannot change them. State is internal and the component controls it. A value should be state if it changes over time and those changes should cause the UI to update. If a value is computed from props or other state, it should be a derived value (a regular variable), not additional state. Keeping your state minimal avoids bugs and unnecessary complexity.

Code examples

Basic useState Pattern

// Simulating useState behavior
function createState(initialValue) {
  let state = initialValue;
  const getState = () => state;
  const setState = (newValue) => {
    if (typeof newValue === 'function') {
      state = newValue(state);
    } else {
      state = newValue;
    }
  };
  return [getState, setState];
}

const [getCount, setCount] = createState(0);

console.log('Initial: ' + getCount());

setCount(5);
console.log('After setCount(5): ' + getCount());

setCount(prev => prev + 1);
console.log('After increment: ' + getCount());

useState returns a value and a setter. The setter can take a direct value or a function that receives the previous state and returns the new state.

State vs Props

// Props: passed from parent, read-only
// State: managed internally, changeable

function simulateComponent(props) {
  // State is local - component owns it
  let state = { clicks: 0 };

  function handleClick() {
    state = { clicks: state.clicks + 1 };
  }

  // Initial render
  console.log(props.label + ' - Clicks: ' + state.clicks);

  // Simulate user clicks
  handleClick();
  handleClick();
  handleClick();

  // After state changes
  console.log(props.label + ' - Clicks: ' + state.clicks);

  // Props are still unchanged
  console.log('Prop label unchanged: ' + props.label);
}

simulateComponent({ label: 'Submit Button' });

Props come from outside and don't change. State is internal and changes in response to user interaction, causing re-renders.

Functional Updates (Avoiding Stale State)

// Problem: multiple direct updates use stale state
function directUpdates() {
  let count = 0;
  // These all see count as 0
  count = count + 1;  // 0 + 1 = 1
  count = 0 + 1;      // stale! still reads original 0
  count = 0 + 1;      // stale! still reads original 0
  return count;
}

// Solution: functional updates always get latest state
function functionalUpdates() {
  let count = 0;
  const update = (fn) => { count = fn(count); };
  update(prev => prev + 1);  // 0 -> 1
  update(prev => prev + 1);  // 1 -> 2
  update(prev => prev + 1);  // 2 -> 3
  return count;
}

console.log('Direct (stale): ' + directUpdates());
console.log('Functional (correct): ' + functionalUpdates());

When you need to update state based on the previous value, always use the functional form (prev => prev + 1) to avoid stale state bugs.

Key points

Concepts covered

useState, state updates, re-rendering, state vs props, initial state