On this page

useState and Events: Interactive React Components

15 min read TextCh. 2 — State and Effects

What is State?

While props are data passed into a component from outside, state is data managed inside a component that can change over time. When state changes, React re-renders the component with the new values.

The useState hook is the most fundamental way to add state to a functional component:

import { useState } from 'react';

function Toggle() {
  const [isOn, setIsOn] = useState(false); // initial value is false

  return (
    <button type="button" onClick={() => setIsOn(!isOn)}>
      {isOn ? 'ON' : 'OFF'}
    </button>
  );
}

useState returns a tuple: the current state value and a setter function. React guarantees that the setter always has a stable identity — you do not need to list it in dependency arrays.

Type Inference with useState

TypeScript infers the type of state from the initial value:

const [count, setCount] = useState(0);         // number
const [name, setName] = useState('');           // string
const [isOpen, setIsOpen] = useState(false);    // boolean

When the initial value does not convey the type (e.g., arrays or objects that start empty), provide the generic explicitly:

interface Notification {
  id: string;
  message: string;
  type: 'info' | 'warning' | 'error';
}

const [notifications, setNotifications] = useState<Notification[]>([]);
const [activeUser, setActiveUser] = useState<User | null>(null);

Handling Events

React events are synthetic events — wrappers around native browser events that work consistently across all browsers. They follow the camelCase naming convention and receive a typed event object:

import { type ChangeEvent, type FormEvent, type MouseEvent } from 'react';

function EventDemo() {
  function handleClick(e: MouseEvent<HTMLButtonElement>) {
    console.log('Button clicked at:', e.clientX, e.clientY);
  }

  function handleChange(e: ChangeEvent<HTMLInputElement>) {
    console.log('New value:', e.target.value);
  }

  function handleSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault(); // always prevent default on forms
    console.log('Form submitted');
  }

  return (
    <form onSubmit={handleSubmit}>
      <input type="text" onChange={handleChange} />
      <button type="button" onClick={handleClick}>Click me</button>
      <button type="submit">Submit</button>
    </form>
  );
}

Common Event Types

Element Event Type
<button> onClick MouseEvent<HTMLButtonElement>
<input> onChange ChangeEvent<HTMLInputElement>
<select> onChange ChangeEvent<HTMLSelectElement>
<textarea> onChange ChangeEvent<HTMLTextAreaElement>
<form> onSubmit FormEvent<HTMLFormElement>
<input> onKeyDown KeyboardEvent<HTMLInputElement>
any element onFocus FocusEvent<HTMLElement>

Updating Objects in State

When your state is an object, you must create a new object rather than modifying the existing one. Use the spread operator:

interface UserProfile {
  name: string;
  email: string;
  age: number;
}

function ProfileEditor() {
  const [profile, setProfile] = useState<UserProfile>({
    name: 'Alice',
    email: '[email protected]',
    age: 28,
  });

  function handleNameChange(e: ChangeEvent<HTMLInputElement>) {
    // Create a new object with all old fields + updated name
    setProfile({ ...profile, name: e.target.value });
  }

  function handleAgeChange(e: ChangeEvent<HTMLInputElement>) {
    setProfile((prev) => ({ ...prev, age: Number(e.target.value) }));
  }

  return (
    <form>
      <input
        type="text"
        value={profile.name}
        onChange={handleNameChange}
        placeholder="Name"
      />
      <input
        type="number"
        value={profile.age}
        onChange={handleAgeChange}
        placeholder="Age"
      />
      <p>Hello, {profile.name} ({profile.age})</p>
    </form>
  );
}

Updating Arrays in State

Arrays in state must also be replaced, not mutated. React array patterns:

interface Task {
  id: number;
  text: string;
  done: boolean;
}

function TaskManager() {
  const [tasks, setTasks] = useState<Task[]>([]);

  // ADD — spread old array + new item
  function addTask(text: string) {
    setTasks((prev) => [
      ...prev,
      { id: Date.now(), text, done: false },
    ]);
  }

  // REMOVE — filter out the item
  function removeTask(id: number) {
    setTasks((prev) => prev.filter((t) => t.id !== id));
  }

  // UPDATE — map and replace matching item
  function toggleTask(id: number) {
    setTasks((prev) =>
      prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
    );
  }

  // REORDER — create a new sorted array
  function sortByName() {
    setTasks((prev) => [...prev].sort((a, b) => a.text.localeCompare(b.text)));
  }

  return (
    <ul>
      {tasks.map((task) => (
        <li key={task.id}>
          <input
            type="checkbox"
            checked={task.done}
            onChange={() => toggleTask(task.id)}
            id={`task-${task.id}`}
          />
          <label htmlFor={`task-${task.id}`}>{task.text}</label>
          <button type="button" onClick={() => removeTask(task.id)}>
            Delete
          </button>
        </li>
      ))}
    </ul>
  );
}

Controlled vs. Uncontrolled Inputs

An controlled input is one whose value is driven by React state. An uncontrolled input stores its value in the DOM (accessed via a ref):

// Controlled — React owns the value
function ControlledSearch() {
  const [query, setQuery] = useState('');

  return (
    <input
      type="search"
      value={query}
      onChange={(e) => setQuery(e.target.value)}
      placeholder="Search..."
    />
  );
}

// Uncontrolled — DOM owns the value
import { useRef } from 'react';

function UncontrolledForm() {
  const inputRef = useRef<HTMLInputElement>(null);

  function handleSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    console.log(inputRef.current?.value);
  }

  return (
    <form onSubmit={handleSubmit}>
      <input type="text" ref={inputRef} />
      <button type="submit">Submit</button>
    </form>
  );
}

In React, controlled inputs are almost always preferred because they keep the UI in sync with JavaScript state, enabling features like real-time validation, submission formatting, and conditional disabling.

Multiple State Variables vs. One Object

You can use multiple useState calls or group related state into a single object:

// Multiple variables — clear separation, easy to update individually
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<Data | null>(null);

// Single object — good for closely related fields
const [formState, setFormState] = useState({
  firstName: '',
  lastName: '',
  email: '',
});

Prefer multiple useState calls for independent pieces of state. Group into an object when values always change together (e.g., form fields).

Lifting State Up

When multiple components need to share the same state, lift it up to their closest common ancestor:

function ParentComponent() {
  const [selectedId, setSelectedId] = useState<number | null>(null);

  return (
    <div>
      {/* Both children share the same state via props */}
      <ItemList selectedId={selectedId} onSelect={setSelectedId} />
      <ItemDetail selectedId={selectedId} />
    </div>
  );
}

In the next lesson, you will learn about useEffect — the hook that synchronizes your components with external systems like APIs, timers, and subscriptions.

Never mutate state directly
Always create a new object or array when updating state. Direct mutation (e.g., `items.push(newItem)`) does not trigger a re-render because React compares by reference. Instead, use spread syntax or array methods that return new arrays: `setItems([...items, newItem])`.
Use the functional update form with previous state
When your new state depends on the previous state, always use the functional form: `setCount((prev) => prev + 1)`. This prevents stale closure bugs in async handlers and event batching scenarios.
React 18+ batches state updates automatically
Starting with React 18, all state updates are automatically batched — even inside async functions, setTimeout callbacks, and native event listeners. This means calling `setA(1)` and `setB(2)` in sequence only triggers one re-render.
import { useState } from 'react';

interface CartItem {
  id: number;
  name: string;
  price: number;
  quantity: number;
}

const INITIAL_ITEMS: CartItem[] = [
  { id: 1, name: 'TypeScript Handbook', price: 29.99, quantity: 1 },
  { id: 2, name: 'React Course', price: 49.99, quantity: 1 },
];

function ShoppingCart() {
  const [items, setItems] = useState<CartItem[]>(INITIAL_ITEMS);

  function handleIncrease(id: number) {
    setItems((prev) =>
      prev.map((item) =>
        item.id === id
          ? { ...item, quantity: item.quantity + 1 }
          : item
      )
    );
  }

  function handleDecrease(id: number) {
    setItems((prev) =>
      prev
        .map((item) =>
          item.id === id
            ? { ...item, quantity: item.quantity - 1 }
            : item
        )
        .filter((item) => item.quantity > 0)
    );
  }

  const total = items.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0
  );

  return (
    <div className="cart">
      {items.map((item) => (
        <div key={item.id} className="cart-item">
          <span>{item.name}</span>
          <button type="button" onClick={() => handleDecrease(item.id)}>−</button>
          <output>{item.quantity}</output>
          <button type="button" onClick={() => handleIncrease(item.id)}>+</button>
          <span>${(item.price * item.quantity).toFixed(2)}</span>
        </div>
      ))}
      <p className="cart-total">Total: ${total.toFixed(2)}</p>
    </div>
  );
}

export default ShoppingCart;