On this page
useState and Events: Interactive React Components
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); // booleanWhen 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.
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;
Sign in to track your progress