Runnable lesson · press Try it on the examples to edit and run them here.
These exercises need useState, events, effects, refs and a custom hook. Solve each one first, then compare.
Exercise: 1. Show and hide a password
A password input with a “Show” button that toggles between showing the text and hiding it. The button label changes to “Hide” while visible.
Hint: type={visible ? "text" : "password"}. —solution
function App() {
const [value, setValue] = React.useState("");
const [visible, setVisible] = React.useState(false);
return (
<div>
<label>
Password{" "}
<input aria-label="Password" type={visible ? "text" : "password"} value={value} onChange={(e) => setValue(e.target.value)} />
</label>{" "}
<button type="button" onClick={() => setVisible(!visible)} aria-pressed={visible}>{visible ? "Hide" : "Show"}</button>
</div>
);
}
Exercise: 2. A temperature slider
Use <input type="range"> from 0 to 40. Show the value in degrees and a word: “Cold” below 10, “Warm” from 10 to 25, “Hot” above 25. The word must not be stored in state.
Hint: Derived State. —solution
function App() {
const [temp, setTemp] = React.useState(15);
const feel = temp < 10 ? "Cold" : temp <= 25 ? "Warm" : "Hot"; // derived, not state
return (
<div>
<label>
Temperature{" "}
<input type="range" min="0" max="40" value={temp} onChange={(e) => setTemp(Number(e.target.value))} aria-label="Temperature" />
</label>
<p>{temp} degrees: {feel}</p>
</div>
);
}
Exercise: 3. Live profile preview
Two inputs (name and bio) and, beside them, a preview card showing what is typed. If the name is empty, the preview shows “Your name”. Use one state object and one change handler.
Hint: Controlled Forms. —solution
function App() {
const [profile, setProfile] = React.useState({ name: "", bio: "" });
const handleChange = (e) => setProfile({ ...profile, [e.target.name]: e.target.value });
return (
<div style={{ display: "flex", gap: 24, flexWrap: "wrap" }}>
<form onSubmit={(e) => e.preventDefault()} style={{ display: "grid", gap: 8 }}>
<input name="name" aria-label="name" placeholder="Name" value={profile.name} onChange={handleChange} />
<textarea name="bio" aria-label="bio" placeholder="Bio" value={profile.bio} onChange={handleChange} />
</form>
<aside style={{ border: "1px solid #ccc", borderRadius: 8, padding: 12, minWidth: 180 }}>
<h3 style={{ margin: 0 }}>{profile.name || "Your name"}</h3>
<p style={{ margin: 0 }}>{profile.bio}</p>
</aside>
</div>
);
}
Exercise: 4. A shopping list with removal
Add items from an input and remove a single item with a button next to it. Items can repeat, so each needs its own id. Show a message when the list is empty.
Hint: crypto.randomUUID() in the handler, filter to remove (Updating Arrays in State). —solution
function App() {
const [items, setItems] = React.useState([]);
const [text, setText] = React.useState("");
function add(e) {
e.preventDefault();
if (!text.trim()) return;
setItems([...items, { id: crypto.randomUUID(), name: text.trim() }]);
setText("");
}
return (
<div>
<form onSubmit={add}>
<input aria-label="Item" value={text} onChange={(e) => setText(e.target.value)} />
<button type="submit">Add</button>
</form>
{items.length === 0 ? (
<p>List is empty</p>
) : (
<ul>
{items.map((item) => (
<li key={item.id}>
{item.name}{" "}
<button aria-label={"Remove " + item.name} onClick={() => setItems(items.filter((i) => i.id !== item.id))}>Remove</button>
</li>
))}
</ul>
)}
</div>
);
}
Both items are called “Milk”, yet each button removes only its own row, because the ids are different. That is why ids matter more than names.
Exercise: 5. A countdown timer
Count down from 5 to 0 with an interval that stops at 0. A Restart button starts it again. Clean up the timer.
Hint: useEffect with setInterval, an updater function and a cleanup (Cleanup Functions). —solution
function App() {
const [left, setLeft] = React.useState(5);
const [run, setRun] = React.useState(0); // changing this restarts the effect
React.useEffect(() => {
setLeft(5);
const id = setInterval(() => {
setLeft((s) => {
if (s <= 1) { clearInterval(id); return 0; }
return s - 1;
});
}, 1000);
return () => clearInterval(id);
}, [run]);
return (
<div>
<h3>Countdown</h3>
<p>Remaining: {left}</p>
{left === 0 && <p role="status">Time is up</p>}
<button onClick={() => setRun(run + 1)}>Restart</button>
</div>
);
}
Exercise: 6. Loading, error and retry
Write loadQuote() that returns a promise: it fails the first time and succeeds afterwards. Show “Loading…”, then an error with a Try again button, then the quote.
Hint: a status state and an attempt counter (Error States). —solution
let calls = 0;
function loadQuote() {
calls += 1;
return new Promise((resolve, reject) =>
setTimeout(() => (calls === 1 ? reject(new Error("Network error")) : resolve("Simplicity is the soul of efficiency.")), 500)
);
}
function App() {
const [state, setState] = React.useState({ status: "loading" });
const [attempt, setAttempt] = React.useState(0);
React.useEffect(() => {
let ignore = false;
setState({ status: "loading" });
loadQuote()
.then((quote) => { if (!ignore) setState({ status: "success", quote }); })
.catch((e) => { if (!ignore) setState({ status: "error", message: e.message }); });
return () => { ignore = true; };
}, [attempt]);
if (state.status === "loading") return <p>Loading...</p>;
if (state.status === "error") {
return (
<div role="alert">
<p>Could not load the quote ({state.message}).</p>
<button onClick={() => setAttempt(attempt + 1)}>Try again</button>
</div>
);
}
return <blockquote>{state.quote}</blockquote>;
}
Exercise: 7. Focus and clear
An input with a “Clear and focus” button that empties the input and puts the cursor back in it. Use a ref.
Hint: useRef. —solution
function App() {
const [text, setText] = React.useState("");
const inputRef = React.useRef(null);
function clear() {
setText("");
inputRef.current.focus();
}
return (
<div>
<input ref={inputRef} aria-label="Notes" value={text} onChange={(e) => setText(e.target.value)} />
<button onClick={clear}>Clear and focus</button>
</div>
);
}
Exercise: 8. A useCounter hook
Write useCounter(initial, { min, max }) that returns { count, increment, decrement, reset } and never leaves the range. Use it for two independent counters.
Hint: Custom Hooks. —solution
function useCounter(initial = 0, { min = -Infinity, max = Infinity } = {}) {
const [count, setCount] = React.useState(initial);
const clamp = (n) => Math.min(max, Math.max(min, n));
return {
count,
increment: () => setCount((c) => clamp(c + 1)),
decrement: () => setCount((c) => clamp(c - 1)),
reset: () => setCount(initial),
};
}
function App() {
const apples = useCounter(0, { min: 0, max: 5 });
const pears = useCounter(3, { min: 0, max: 10 });
return (
<div>
<p>Apples: {apples.count}</p>
<button onClick={apples.increment}>Add apple</button>
<p>Pears: {pears.count}</p>
<button onClick={pears.increment}>Add pear</button>
</div>
);
}
Each call to useCounter has its own state, so the two counters do not affect each other.
Next
Move on to the debugging challenges: Debugging Challenges.