# State and Events

# useState

A function component stores state with the useState hook. It returns the current value and a setter; calling the setter schedules a re-render.

import { useState } from 'react';

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

  return (
    <button type="button" onClick={() => setCount(count + 1)}>
      Clicked {count} times
    </button>
  );
}

The generic (useState<number>) is often inferred from the initial value; write it explicitly when the initial value does not fully describe the type (useState<Product | null>(null)).

# Event handlers are passed, not invoked

onClick={handler} registers the function; onClick={handler()} calls it during render and registers the return value—usually undefined. Prefer a native, accessible element (<button>, <a>) over <div onClick>: buttons get keyboard focus and activation for free, a div does not. In reusable examples give the button type="button" so it cannot accidentally submit a surrounding form.

<button type="button" onClick={increment}>Add</button>
<button type="button" onClick={() => increment(id)}>Add with id</button>

The buggy form onClick={increment()} runs during render, not on click.

# State is a snapshot

Inside one render, count is a fixed value. Updating it does not change the variable the current render sees:

const onClick = () => {
  setCount(count + 1);
  console.log(count);
};

The console.log above still prints the old value for this render. The same thing happens in a class handler—inside a class whose state contains clickCount, this handler:

onClickHandler() {
  this.setState({
    clickCount: this.state.clickCount + 1
  });

  console.log(`Clicks: ${this.state.clickCount}`);
}

The log prints the previous value because setState does not mutate this.state synchronously in the handler.

React also batches multiple updates in one event handler into a single re-render. If the new state depends on the old, use the updater form so each update sees the latest value:

setCount(count + 1);
setCount(count + 1);

Both calls above compute from the same snapshot, so count grows by one. With the updater form the calls chain, so count grows by two:

setCount((c) => c + 1);
setCount((c) => c + 1);

# Update state immutably

React compares references to decide what changed. Never mutate the existing object or array; create a new one:

setProducts([...products, newProduct]);
setProducts(products.filter((p) => p.id !== id));
setProduct({ ...product, price: product.price + 1 });

Keep data in state, not JSX elements—store plain values and render elements from them. This component is intentionally broken:

import { useState } from 'react';

function BrokenList() {
  const [items, setItems] = useState<string[]>([]);
  const [clicks, setClicks] = useState(0);

  const onAdd = () => {
    items.push('New item');
    setItems(items);
    setClicks((c) => c + 1);
  };

  return (
    <section>
      <button type="button" onClick={onAdd}>Add</button>
      <p>{items.join(', ')}</p>
      <p>Clicks: {clicks}</p>
    </section>
  );
}

items.push('New item') mutates the array in place, and setItems(items) passes the same reference back—same-reference updates may bail out. The UI still appears to update because the unrelated setClicks triggers a render that picks up the mutated array: a second update masking the mutation bug, not correct behavior. Remove the counter and the list stops updating.

The fix appends immutably with a functional updater:

const onAdd = () => {
  setItems((items) => [...items, 'New item']);
};

# Controlled inputs

Keep input values in state so React is the single source of truth:

function Filter({ value, onChange }: { value: string; onChange: (v: string) => void }) {
  return (
    <label>
      Filter:
      <input value={value} onChange={(e) => onChange(e.target.value)} />
    </label>
  );
}

A checkbox uses checked and e.currentTarget.checked instead of value. In a controlled component the flow is circular: the parent passes checked down, the child reports changes through onChange, and the parent updates its own state—one source of truth:

import { useState } from 'react';

function SelectionToggle({ checked, onChange }: { checked: boolean; onChange: (checked: boolean) => void }) {
  return (
    <label>
      In stock only
      <input
        type="checkbox"
        checked={checked}
        onChange={(e) => onChange(e.currentTarget.checked)}
      />
    </label>
  );
}

export default function ProductSelection() {
  const [selected, setSelected] = useState(false);

  return (
    <section>
      <SelectionToggle checked={selected} onChange={setSelected} />
      <p>{selected ? 'In-stock filter enabled' : 'In-stock filter disabled'}</p>
    </section>
  );
}

The checkbox value now lives in ProductSelection; SelectionToggle stays a thin controlled child. The class section below shows the related pitfall of copying a prop into state once and never updating it.

# Lifting state up

When two components need the same data, move the state to their common parent and pass it down with a callback. This preserves one-way data flow: the child asks the owner to change the data. Values that can be computed from state during render—like visible below—are derived and should not be duplicated into a second state variable.

import { useRef, useState } from 'react';

interface Product {
  id: number;
  name: string;
  price: number;
}

const initialProducts: Product[] = [
  { id: 1, name: 'Coffee', price: 120 },
  { id: 2, name: 'Tea', price: 80 },
];

function Filter({ value, onChange }: { value: string; onChange: (v: string) => void }) {
  return (
    <label>
      Filter:
      <input value={value} onChange={(e) => onChange(e.target.value)} />
    </label>
  );
}

function ProductList({ products }: { products: Product[] }) {
  return (
    <ul>
      {products.map((p) => (
        <li key={p.id}>{p.name} — ${p.price}</li>
      ))}
    </ul>
  );
}

export default function ProductApp() {
  const [products, setProducts] = useState<Product[]>(initialProducts);
  const [filter, setFilter] = useState('');
  const nextId = useRef(3);

  const visible = products.filter((p) =>
    p.name.toLowerCase().includes(filter.toLowerCase())
  );

  const addProduct = (name: string) => {
    const product: Product = { id: nextId.current++, name, price: 0 };
    setProducts((ps) => [...ps, product]);
  };

  return (
    <main>
      <Filter value={filter} onChange={setFilter} />
      <ProductList products={visible} />
      <button type="button" onClick={() => addProduct('New product')}>
        Add
      </button>
    </main>
  );
}

Note where the id is allocated: nextId.current++ runs in the event handler before the pure updater, not inside it. Updaters must be pure (React may call them more than once), so id generation with a ref counter—or any impure work—does not belong inside setProducts.

# Class components and setState

Class components keep state in this.state and update it with this.setState, which merges the provided fields into existing state (unlike useState, where the setter replaces the value). setState also accepts an updater function (prevState) => partial, which is the class counterpart of the hook's functional form:

import * as React from 'react';

class Counter extends React.Component<Record<string, never>, { count: number; lastClick: string }> {
  state = { count: 0, lastClick: 'never' };

  increment = () => {
    this.setState((prev) => ({ count: prev.count + 1 }));
  };

  render() {
    return (
      <button type="button" onClick={this.increment}>
        {this.state.count} — {this.state.lastClick}
      </button>
    );
  }
}

Here this.setState({ count: ... }) merges: lastClick stays. With hooks the equivalent is setState((s) => ({ ...s, count: s.count + 1 })), because the hook setter replaces the whole state object.

A common class-era pitfall is copying a prop into state in the constructor:

import * as React from 'react';

interface InitialSelectionProps {
  checked: boolean;
}

interface InitialSelectionState {
  checked: boolean;
}

class InitialSelection extends React.Component<InitialSelectionProps, InitialSelectionState> {
  constructor(props: InitialSelectionProps) {
    super(props);
    this.state = { checked: props.checked };
  }

  render() {
    return (
      <label>
        Selected
        <input
          type="checkbox"
          checked={this.state.checked}
          onChange={(e) => this.setState({ checked: e.currentTarget.checked })}
        />
      </label>
    );
  }
}

this.state = { checked: props.checked } only initializes state—if the parent later passes a new checked, the component keeps the stale copy. If the value must track the parent, use the controlled pattern from the previous section instead; if it is genuinely only a starting value, name the prop accordingly (initialChecked) so the intent is clear.

Try it: fix BrokenList from the immutability section—replace the in-place push/setItems(items) with the immutable updater, delete the masking clicks counter, and explain why the original version still appeared to work.