React Foundations Workbook
0% of missions checked
Frontend Interview Prep · Build & Rebuild

React Foundations

One mental model, built up one layer at a time. Get a small thing working, then add the next: Counter, then Todo, then Accordion, all in plain JavaScript. TypeScript is the last layer, not the first.

Start here — read this one screen

User action
click / type
Event handler
runs JS
setState
ask to change
React renders
recalculates
Screen
updates

This loop is the whole course. You will meet it in Counter, again in Todo, again in Accordion. Learn it once.

You are learningThe one loop above, in plain JavaScript, through three projects: Counter → Todo → Accordion. Plus the handful of interview words that hang off it: state, props, events, lists + keys, controlled inputs, conditional rendering.
Later layers — on purposeTypeScript, useEffect, Redux / Zustand, React Query, Next.js, routing, Context, reducers, useMemo / useCallback, performance, server components. All real, all worth knowing, just later. The foundation holds first; these layer on cleanly once it does.

How to use this workbook

Type itNever copy-paste project code. Typing is where the learning is.
Predict firstEvery Predict box is closed on purpose. Guess out loud, then reveal.
Earn the rescueRescue levels are collapsed. Open Rescue 1 only after a real attempt. The full answer is Rescue 5, the last resort.
Rebuild coldEach project ends with a blank-file rebuild. That, not “I finished the lesson,” is mastery.
The doing carries the learningThis workbook is built so the missions teach, not the paragraphs. Spend your time in the Predict, Build, and rebuild steps. That is where React sticks.

Your status legend

Each project ends with a self-check. Be honest. The goal is the top tier, under interview pressure.

🔴 Building 🟡 Can build with prompts 🟢 Build independently Explain & modify under pressure
Mission 0
MISSION 0

Set up React

Goal: one local React app running, and you know exactly which file to edit.
What are we doing here, and why?

This mission is pure setup: install the tools, scaffold a project, and get a dev server running with instant reload.

There is no React concept to learn yet, so do not over-study it. The point is a working foundation you build only once. Every later mission assumes this is done, so getting it solid now means you never have to think about tooling again.

The six things & what each one does

ToolIts one job
NodeRuns JavaScript tooling on your machine.
npmInstalls and runs packages. Ships with Node.
ViteCreates the project and runs the dev server.
ReactThe library you are actually here to learn.
VS CodeThe editor + a built-in terminal.
BrowserWhere the running app appears.

The setup mission, in order

VS Code
Terminal
Vite project
npm install
npm run dev
Browser
A · Install B · Verify Node
node -v npm -v
How you know it workednode -v prints a version starting with v24 (or v20/v22). npm’s number can differ, and that’s fine. If “command not found”: close and reopen VS Code so it picks up Node, then retry.
C · Create & run the app

Run one line at a time:

npm create vite@latest counter-app -- --template react cd counter-app npm install npm run dev
How you know it workedThe terminal prints a local address, usually http://localhost:5173. Open it and you’ll see the Vite + React starter page. Leave the terminal running while you code; press Ctrl + C to stop it later.
D · Clear the starter demo

The template fills the project with a demo: spinning logos, a counter, and global styles. Strip it back to a blank slate so nothing fights the code you write next. First, cut src/App.jsx down to an empty component:

export default function App() { return null; }
How you know it workedSave every file. The browser shows a plain blank page: white, no logos, and no errors in the terminal or the browser console. Blank is exactly right; you fill it in Mission 1, next.
If stuck Setup troubleshooting
SymptomTry this
node: command not foundNode not installed, or terminal needs a restart. Reinstall Node LTS, reopen VS Code, retry node -v.
npm create failsCheck internet. Retry from a normal user folder (not a system folder). Read the exact terminal error.
“Port already in use”An old dev server is still running. Use the alternate port Vite offers, or stop the old terminal with Ctrl + C.
Blank pageA code error. Read the browser console and the VS Code terminal before changing anything else.
🎯
Interview-adjacent: Vite, not Create React App. CRA is deprecated; the React docs list Vite as a from-scratch option. Knowing the modern toolchain is a small credibility signal.
Done when

The dev server is running, the starter page loads in your browser, and you can point to the file you’ll edit next: src/App.jsx.

Mission 1
MISSION 1

Make one thing appear on the screen

Before any state or events: prove you can put your own words on screen and know why.
What are we doing here, and why?

Before any buttons or state, you prove one thing: you can put your own text on screen from a component and understand why it shows up.

The point is the single idea the whole library rests on: a component is a function that returns JSX, and saving the file updates the browser instantly. Everything after this is that same idea with more moving parts.

The only four files you care about

FileWhat it is
index.htmlBrowser entry point. Holds <div id="root"></div>. Leave it alone.
src/main.jsxStarts React by rendering <App /> into that root div. Leave it alone.
src/App.jsxYour main component. Almost all your work happens here.
src/App.cssStyles. Optional for now; ignore it.
See

Back in the now-empty src/App.jsx, make its entire contents exactly this:

export default function App() { return <h1>Hello React</h1>; }
Predict

Before you save: what appears in the browser? And is that line HTML?

Reveal after you guess

The starter page is replaced by a big Hello React heading. And no. It only looks like HTML. That is JSX: HTML-shaped syntax that lives inside JavaScript and gets compiled to real function calls by Vite.

Run

Save the file. Vite hot-reloads, and the browser updates in well under a second, no refresh needed. That instant feedback loop is your whole workflow now.

Explain (say it out loud)
First mental modelA React component is just a JavaScript function that returns JSX. App is a function; the JSX it returns describes what shows on screen. Capital A matters: React treats capitalized names as components, lowercase as plain HTML tags.
Break → predict → fix

Try to return two lines with no wrapper:

return ( <h1>Hello React</h1> <p>I am learning</p> );
What breaks, and why?

Error: a component can only return one top-level element. Wrap the two lines in one parent: a <div>, or an empty <>...</> fragment. Fix it, save, and watch both lines appear.

Done when

Your own text is on screen, edits hot-reload instantly, and you can finish the sentence: “A component is a function that…”

Mission 2 · Project 1
MISSION 2

Counter

Your first real React: state, events, rendering, and the updater pattern.
What are we building, and why does it matter?

You are building the smallest possible interactive app: a number on screen with buttons that change it. It looks trivial, and that is exactly why it comes first.

The point is that Counter exercises the entire React loop in one tiny program: state (the number React remembers), an event (a button click), the setter (setCount), and a re-render (the new number appears). Once this loop feels automatic, Todo and Accordion are the same loop with more pieces bolted on.

It is also the classic interview warm-up, and the “+1 twice” trap further down is a favourite follow-up. Small program, whole mental model.

State
count: 0
what it remembers
Click +1
setCount(c => c+1)
the event
Render
Count: 1
new screen

State is memory. Calling the setter asks React to render again with a new value.

Before you code — answer out loud
About useState: what it is, and what setCount does

A React component is just a function, and React re-runs that whole function every time the screen needs to update. A plain let count = 0 is no use for that: it resets to 0 on every run, and changing it never tells React to redraw. useState is what fixes both problems. It gives you a value that survives across renders and a way to change it that tells React to render again. That is its whole purpose.

const [count, setCount] = useState(0);

That one line has three parts worth naming out loud:

PartWhat it is
useState(0)You call it once with the initial value (here 0). It hands back an array of exactly two things.
countThe current value for this render. Read it anywhere in your JSX. You never assign to it directly.
setCountThe setter function. Call setCount(next) to change the value. Going through the setter is the only way to update it.

The square brackets are array destructuring: useState returns [value, setter] and you choose both names. The convention is x and setX, which is why useState(0) becomes count and setCount here.

What setCount actually doesCalling setCount(1) does two jobs at once: it stores 1 as the new count, and it asks React to re-render the component. On that next render, useState hands back 1 instead of 0, so the screen shows the new number. That is the entire loop: setter → re-render → new value on screen. This is also why a plain variable fails: it changes no stored state and asks for no re-render.
Build it in three passes
PassDo thisWhy
1Show a static Count: 0, then swap the 0 for {count} from useState(0).Curly braces embed a JS value in JSX; state is the memory.
2Add a +1 button that calls a handler.An event calls the setter.
3Add −1 and Reset.One state value drives many buttons.
Stop and buildAttempt all three passes now. Only open the rescue ladder if you’re actually stuck, and start at Rescue 1.
Predict — the interview classic

Your +1 handler runs these two lines. After one click, what is the count?

setCount(count + 1); setCount(count + 1);
Reveal after you guess

It goes up by 1, not 2. During this render, count is a fixed snapshot (say 0). Both lines compute 0 + 1, so both ask for 1.

To get +2, use the updater form, where each call receives the latest queued value:

setCount(c => c + 1); // 0 -> 1 setCount(c => c + 1); // 1 -> 2
Beginner habitWhenever the next value depends on the previous value, prefer setCount(c => c + 1). This same rule scales to arrays and objects in the next project.
Rescue ladder — earn each rung
Rescue 1 A question to unstick yourself

What does the UI need to remember? What event changes that memory? Which function actually changes it?

Rescue 2 A concept, no code

useState(0) hands you a pair: the current value and a setter. The number on screen is that value. A button’s onClick calls the setter, which asks React to render again.

Rescue 3 Pseudocode
// top of component const [count, setCount] = useState(0) // handlers increment: setCount(c => c + 1) decrement: setCount(c => c - 1) reset: setCount(0) // render show count, and 3 buttons wired to the handlers
Rescue 4 One real fragment
const [count, setCount] = useState(0); function increment() { setCount(c => c + 1); } // now write decrement, reset, and the JSX yourself <button onClick={increment}>+1</button>
Rescue 5 Full reference — last resort
import { useState } from 'react'; export default function App() { const [count, setCount] = useState(0); function increment() { setCount(c => c + 1); } function decrement() { setCount(c => c - 1); } function reset() { setCount(0); } return ( <main> <h1>Counter</h1> <p>Count: {count}</p> <button onClick={decrement}>−1</button> <button onClick={reset}>Reset</button> <button onClick={increment}>+1</button> </main> ); }
The one line to internalizeRead setCount(c => c + 1) as: “React, take the latest count and give me the next one.”
Done when

+1, −1, and Reset all work, and you can say in one sentence what useState returns.

Counter Boss

Rebuild + mutate under pressure

Completion is not mastery. Rebuilding from nothing is.

⏱️ 10-minute blank rebuild
Cold rebuild Interview mutations — change one requirement, don’t restart
MutationDo thisSkill it drills
+5Add a button that adds 5.Same state, new event.
Floor at 0Never let count go below 0.Compute the next value safely.
“High”Show the word High only when count ≥ 10.Conditional rendering.
Step inputAdd a number input; +/− move by that step.A second, independent piece of state.
Live labelButton text changes based on the count.UI derived from state.

Try each one yourself first. Solutions are collapsed; open one only to check.

+5 Solution
<button onClick={() => setCount(c => c + 5)}>+5</button>
Floor at 0 Solution
function decrement() { setCount(c => Math.max(0, c - 1)); }
“High” Solution
{count >= 10 && <span>High</span>}
Step input Solution
const [step, setStep] = useState(1); <input type="number" value={step} onChange={e => setStep(Number(e.target.value))} /> <button onClick={() => setCount(c => c + step)}>+{step}</button>
Live label Solution
<button onClick={increment}> {count >= 10 ? 'Easy now' : 'Add one'} </button>
Bug hunt — find the one thing wrong
Bug 1 This +1 button does nothing useful
Show the buggy code
let count = 0; <button onClick={() => count++}>{count}</button>
Show the fixed code
const [count, setCount] = useState(0); <button onClick={() => setCount(c => c + 1)}>{count}</button>

A plain variable is not state. Changing count doesn’t tell React to re-render, and it resets every render anyway. useState keeps the value between renders and its setter triggers the re-render.

Bug 2 This one loops or updates during render
Show the buggy code
<button onClick={setCount(count + 1)}>+1</button>
Show the fixed code
<button onClick={() => setCount(c => c + 1)}>+1</button>

onClick={setCount(count + 1)} calls the setter while rendering, which triggers another render, and another. Pass a function so it runs on the click instead: onClick={() => setCount(c => c + 1)}.

Final clean Counter
Solution Full clean Counter (all mutations folded in)
import { useState } from 'react'; export default function App() { const [count, setCount] = useState(0); function increment() { setCount(c => c + 1); } function decrement() { setCount(c => Math.max(0, c - 1)); } // floor at 0 function reset() { setCount(0); } return ( <main> <h1>Counter</h1> <p>Count: {count}</p> {count >= 10 && <p>High</p>} <button onClick={decrement}>−1</button> <button onClick={reset}>Reset</button> <button onClick={increment}>+1</button> </main> ); }
How solid is Counter? Tap your honest level (saved on this device):
Counter mastery check
Missions 3–7 · Project 2 · Todo List
newTask
text
controlled input
Add event
make a todo
new object
todos
[ … ]
{ id, text, done }
Render
todos.map()
one <li> each
The golden rule for the whole projectNever mutate the old array. Build a new array with spread, map, or filter. React treats state as read-only.

You will build this in five small missions. Each adds exactly one idea. Do not try to do them all at once.

MISSION 3

Todo — render a list

One idea: turn an array into UI with map + a stable key.
What are we doing here, and why?

You turn a plain array of data into a list of elements on screen with map.

Almost every real interface is “a list of things,” so this pattern is everywhere. The key you attach to each item is what lets React update the right row later without bugs, which is why it is not just decoration.

Build

Start from a hard-coded array. No input, no state yet.

const todos = [ { id: 1, text: 'Learn map' }, { id: 2, text: 'Learn keys' }, ]; return ( <ul> {todos.map(todo => ( <li key={todo.id}>{todo.text}</li> ))} </ul> );
Predict

What if you used key={index} from map((todo, index) => …) instead?

Reveal after you guess

It renders fine now, which is the trap. The index key breaks later, once items can be added, deleted, or reordered, because the index no longer points at the same logical item. A key is React’s identity tag across renders, not a display value. Use todo.id.

Rescue
Rescue 1 Question

map turns each array item into what? And what does React need for each of those items so it can tell them apart between renders?

Rescue 5 Full slice
<ul> {todos.map(todo => ( <li key={todo.id}>{todo.text}</li> ))} </ul>
Done when

The list renders from the array, every <li> has a stable key={todo.id}, and you can explain why the key isn’t just for looks.

MISSION 4

Todo — controlled input

One idea: the input’s value comes from state and flows back to state.
What are we doing here, and why?

You wire a text input so its value lives in React state instead of in the DOM.

A controlled input is the foundation of every form, search box, and filter you will build. Once “value from state, onChange updates state” is automatic, forms stop feeling mysterious.

keyboard
onChange
state
value=
shown
Build
const [text, setText] = useState(''); <input value={text} onChange={event => setText(event.target.value)} />
🎯
Interview skill. Controlled inputs show up constantly: forms, search boxes, autocomplete, filters. If you can explain “value from state, onChange updates state,” you can handle a big share of frontend rounds.
Bug hunt
Bug Why can’t I type into this input?
Show the buggy code
<input value={text} />
Show the fixed code
<input value={text} onChange={e => setText(e.target.value)} />

value={text} pins the input to state, but with no onChange nothing ever updates that state, so the input is frozen (read-only). A controlled input always needs both halves.

Done when

Typing updates text state, the input always reflects that state, and you can name both halves of a controlled input.

Under the hood · How React updates the screen

The machine underneath setState

You have been calling a setter and watching the screen change. The Todo missions lean hard on that, so here is the machine underneath — the rest of the workbook makes far more sense once you can picture it. Open each one; these four questions trip up almost everyone.

What is the DOM, and what does the browser actually do?

When a page loads, the browser reads your HTML and builds a live tree of objects in memory called the DOM (Document Object Model). Every tag becomes a node you can read and change: an <h1> node, an <li> node, a <button> node. The pixels you see are the browser painting that tree.

The browser owns the machine. It parses HTML and CSS, builds the DOM, paints pixels, listens for user events (clicks, keypresses), and runs your JavaScript. That is the platform your app lives on.

Here is the part that surprises people: React never draws a single pixel. React builds a description of what the DOM should be, then edits the real DOM nodes for you. The browser paints the result. The chain is:

your data → React describes the UI → React edits the DOM → the browser paints

The whole reason React exists is to spare you from hand-writing DOM edits like document.querySelector(…).textContent = …. You describe what the UI should look like for your data, and React makes the DOM match.

Who calls render, and when?

“Render” is simply React running your component function to get a fresh description of the UI. You never call it by name. React calls your function for you, in exactly two situations:

  • once when the component first appears on screen (mount), and
  • again every time that component’s state changes (you called a setter) or its props change.

The sequence when you call a setter:

setCount(1) │ ▼ React marks this component "needs a new render" │ ▼ React re-runs your component function ← this is "render" │ ▼ React compares the new description with the old one │ ▼ React changes only the DOM nodes that differ │ ▼ the browser paints

Two things to hold onto: calling a setter does not paint immediately, it schedules a render; and React may batch several setter calls into a single render. You change state, React decides when to render, the browser paints.

What is the goal of state management?

One sentence: keep a single source of truth for anything that changes over time, and make the UI a pure function of it.

UI = f(state)

Given the same state, you always get the same screen. You never reach into the DOM and poke it (“set this text, add that class, hide this node”). You change state and describe what the UI looks like for that state, and React rebuilds the DOM to match.

Why this is the whole game: hand-updated UIs drift out of sync, because you always forget one of the places that showed the same value. When the UI is derived from state, there is nothing to keep in sync — re-render from the current state and the screen is correct by construction.

Good state is minimal: store only the values you cannot compute, and derive the rest during render. That is exactly why the search filter and the “items left” count are computed each render instead of stored — they are already implied by the todos and the search text.

Why do we pass a function to setCount / setText?

A setter accepts either a plain value or a function:

setCount(5) // "make it 5" setCount(c => c + 1) // "whatever it is now, add 1" ← the updater

The key fact people miss: during one render, count is a frozen snapshot, a constant for that pass. So setCount(count + 1) means “set it to this render’s count plus one.” Call it twice and both lines read the same snapshot, so you get +1, not +2.

The function form fixes that. setCount(c => c + 1) hands React a recipe, not a value. React runs each queued recipe in order, passing in the latest value, so two calls really do add up to +2.

That function is a callback: you are not calling it, and you are certainly not calling render. You give React the update rule; React calls your function with the current state, stores the result, and schedules the render. You hand React a plan — React does the work.

Rule of thumb: when the next value depends on the previous value, pass a function. That is why every array update in the Todo project is written this way:

setTodos(current => [...current, newTodo]); // take the latest list, return a new one with the item added

current is the freshest todos array React has. You build a brand-new array from it and return that: no stale snapshot, no mutating the old list, always working from the truth.

MISSION 5

Todo — add one

One idea: form submit builds a new todo object and a new array.
What are we doing here, and why?

You handle a form submit that builds a brand-new todo and adds it to the list.

This is the first time you make a new array instead of changing the old one, the rule that keeps React state predictable. preventDefault and giving each item an id also show up in every real form.

Deeper intuition: what the callback in setTodos is doing

setTodos(current => [...current, newTodo]) reads like a sentence: “React, take the latest todos (current) and give me back a new array that is all of them plus this new one.”

The spread [...current, newTodo] builds a brand-new array: it copies the old items, then appends one. You return it, React stores it as the new state and re-renders. You never touch the old array. That new array reference is exactly how React knows the list changed and the screen needs repainting. (See Under the hood above for why the callback beats setTodos([...todos, newTodo]) when updates can stack up.)

Build

Wrap the input in a <form>. The minimum todo object is just three fields:

const [todos, setTodos] = useState([]); function addTodo(event) { event.preventDefault(); // stop the browser page reload const trimmed = text.trim(); if (!trimmed) return; // ignore empty / whitespace const newTodo = { id: crypto.randomUUID(), text: trimmed, done: false, }; setTodos(current => [...current, newTodo]); // new array setText(''); // clear the input }
<form onSubmit={addTodo}> <label htmlFor="todo-input">New task</label> <input id="todo-input" value={text} onChange={e => setText(e.target.value)} /> <button type="submit">Add</button> </form>
Two details interviewers watch for• A <form> with onSubmit gives you free Enter-to-submit. Call event.preventDefault() or the page reloads.• Buttons inside a form default to type="submit". Make that intentional here; any other button in the form needs type="button".
Why a separate idTwo tasks can have identical text (“call mom” twice). Identity (the id) is what React and your delete/toggle use, not the words on screen.
Rescue
Rescue 1 Question

How do you make a new array that is “everything before, plus one more” without touching the old one?

Rescue 3 Pseudocode
on submit: prevent default trim text; if empty, stop make newTodo { id, text, done:false } setTodos(current => [...current, newTodo]) clear the input
Rescue 5 Full slice
setTodos(current => [...current, newTodo]);

[...current, newTodo] = keep every old todo, then add the new one.

Done when

Submitting adds one todo and clears the input, and empty / whitespace-only entries are rejected.

MISSION 6

Todo — delete one

One idea: filter to a new, smaller array using identity.
What are we doing here, and why?

You remove exactly one item from the list using its id.

filter into a new, smaller array is the delete half of every list UI. Doing it by identity rather than by position or text is what prevents the classic “wrong row disappeared” bug.

Deeper intuition: why filter is the right tool

current.filter(t => t.id !== id) returns a new, smaller array with the matching item left out; it never changes the original. React sees a new array reference, re-renders, compares, and removes just the one <li> whose id you dropped.

Filtering by id (identity) rather than by position or text is what guarantees the right row goes, even when two todos share the same words. This is UI = f(state) in action: you change the data, and the list is recomputed to match — you never hunt down a DOM node to delete yourself.

Build
function deleteTodo(id) { setTodos(current => current.filter(todo => todo.id !== id)); } // in the list: <button type="button" onClick={() => deleteTodo(todo.id)}>Delete</button>
Note the wiringonClick={() => deleteTodo(todo.id)} passes a function. Writing onClick={deleteTodo(todo.id)} would call it during render, the same bug as the Counter boss.
Bug hunt
Bug The list won’t update after “add”
Show the buggy code
todos.push(newTodo); setTodos(todos);
Show the fixed code
setTodos(current => [...current, newTodo]);

push mutates the existing array and hands React the same reference, so React often sees “no change.” Build a new array instead.

Done when

Clicking Delete removes exactly the clicked todo, even when two todos share the same text.

MISSION 7

Todo — toggle done

One idea: map + object spread to replace one item immutably.
What are we doing here, and why?

You flip one item’s done flag while leaving every other item untouched.

map + object spread to replace a single item is the update half of CRUD. Computing “items left” instead of storing it also teaches derived state, the habit that keeps a list from drifting out of sync with itself.

Deeper intuition: new references are the “repaint this” signal

To flip one todo, map returns a new array, and for the single match { ...todo, done: !todo.done } builds a new object. Every other todo is the same object, reused untouched.

This matters because React decides what to repaint by comparing references. A fresh array, and a fresh object for the changed row, say “this changed, update it”; the reused objects say “these are identical, leave them.” If you instead mutated todo.done = !todo.done in place, the references would not change and React could skip the update entirely. Fresh references are the signal that tells render there is work to do.

Build
function toggleTodo(id) { setTodos(current => current.map(todo => todo.id === id ? { ...todo, done: !todo.done } : todo ) ); } // in the list: <input type="checkbox" checked={todo.done} onChange={() => toggleTodo(todo.id)} />

Read the three array updates as sentences

Add[...current, newTodo] Togglemap(…)  — same length, one item replaced Deletefilter(…)  — every item except one
The spread nuance{ ...todo, done: !todo.done } makes a new object: copy the old fields, then override done. Every non-matching todo is reused unchanged.
Derived value — don’t store what you can compute
No extra state“Items left” is not its own state. Compute it during render: const left = todos.filter(t => !t.done).length;. Duplicated state is how lists get out of sync.
Done when

The checkbox toggles one todo immutably (via map + spread), and any “items left” count is derived, not stored.

Todo Boss

Rebuild the core CRUD

Add + delete + render, from a blank file, narrating the array updates.

⏱️ 20-minute blank rebuild
Cold rebuild Interview mutations
MutationDo thisSkill
Items leftShow a live count of not-done todos.Derived state.
FiltersAll / Active / Done buttons.Conditional list rendering.
EditMake a todo’s text editable.Update one object with map.
Disable AddDisable the button when input is blank.UI state derived from existing state.
Clear completedRemove all done todos at once.Filter the array.

Try each yourself first; open a solution only to check.

Items left Solution
const remaining = todos.filter(t => !t.done).length; <p>{remaining} left</p> // derived, not its own state
Filters Solution
const [filter, setFilter] = useState('all'); const shown = todos.filter(t => filter === 'active' ? !t.done : filter === 'done' ? t.done : true ); // render All / Active / Done buttons that call setFilter, // then map over `shown` instead of `todos`.
Edit Solution
function editTodo(id, text) { setTodos(cur => cur.map(t => t.id === id ? { ...t, text } : t )); }
Disable Add Solution
<button type="submit" disabled={!text.trim()}>Add</button>
Clear completed Solution
function clearCompleted() { setTodos(cur => cur.filter(t => !t.done)); }
Bug hunt — find the one thing wrong
Bug 1 The list won’t update after “add”
Show the buggy code
todos.push(newTodo); setTodos(todos);
Show the fixed code
setTodos(cur => [...cur, newTodo]);

push mutates the existing array and hands React the same reference, so it often sees “no change.” Build a new array instead.

Bug 2 The wrong row changes after a delete
Show the buggy code
{todos.map((todo, i) => ( <li key={i}>{todo.text}</li> ))}
Show the fixed code
{todos.map(todo => ( <li key={todo.id}>{todo.text}</li> ))}

The array index isn’t stable once items are added, deleted, or reordered, so React reuses the wrong DOM node. Key by a real id.

Bug 3 Delete fires the moment the list renders
Show the buggy code
<button onClick={deleteTodo(todo.id)}>Delete</button>
Show the fixed code
<button onClick={() => deleteTodo(todo.id)}>Delete</button>

Without the arrow, deleteTodo(todo.id) is called during render. Wrap it so it runs on the click.

Bug 4 You can’t type in the input
Show the buggy code
<input value={text} />
Show the fixed code
<input value={text} onChange={e => setText(e.target.value)} />

value={text} pins the input to state, but with no onChange nothing updates that state, so it’s frozen. A controlled input needs both halves.

Rescue 5 Full assembled Todo — last resort only
import { useState } from 'react'; export default function App() { const [text, setText] = useState(''); const [todos, setTodos] = useState([]); function addTodo(event) { event.preventDefault(); const trimmed = text.trim(); if (!trimmed) return; const newTodo = { id: crypto.randomUUID(), text: trimmed, done: false }; setTodos(current => [...current, newTodo]); setText(''); } function toggleTodo(id) { setTodos(current => current.map(todo => todo.id === id ? { ...todo, done: !todo.done } : todo ) ); } function deleteTodo(id) { setTodos(current => current.filter(todo => todo.id !== id)); } return ( <main> <h1>Todo List</h1> <form onSubmit={addTodo}> <label htmlFor="todo-input">New task</label> <input id="todo-input" value={text} onChange={e => setText(e.target.value)} /> <button type="submit">Add</button> </form> <ul> {todos.map(todo => ( <li key={todo.id}> <label> <input type="checkbox" checked={todo.done} onChange={() => toggleTodo(todo.id)} /> {todo.text} </label> <button type="button" onClick={() => deleteTodo(todo.id)}>Delete</button> </li> ))} </ul> </main> ); }
How solid is Todo CRUD?
Todo mastery check
Mission 8 · Project 3
MISSION 8

Accordion

The real lesson: choose a state shape that makes bad UI states impossible.
What are we building, and why does it matter?

You build panels where only one can be open at a time. The code is short; the real exercise is choosing the state.

A single openIndex instead of three separate booleans makes an illegal UI (“all three open”) impossible by construction. Picking the state shape that cannot represent a broken screen is what separates code that works from code that cannot break, and it is exactly the reasoning strong interview answers show.

Deeper intuition: state shape decides what the screen can even be

Because UI = f(state), the shape of your state sets the boundary of every screen that can exist. A single openIndex (a number, or null) can only ever describe “none open” or “exactly one open.” The screen “all three open” is not a bug you have to guard against — with this state shape it is literally unrepresentable.

So choosing the state shape is the design work: you are choosing which screens are reachable at all. Three separate booleans would let an illegal screen exist; one openIndex makes it impossible, and render can only ever draw a legal one.

State
openIndex
null, or one index
Click heading
same? close. new? open
Render
one panel shows

The interview question this asks: what is the minimum state?

If only one panel can be open, the whole UI is captured by a single number (or null). Each screen below is exactly one value of openIndex:

openIndex = null
What is React? +
What is state? +
What are props? +
openIndex = 0
What is React?
A library for building UI.
What is state? +
What are props? +
openIndex = 1
What is React? +
What is state?
Memory between renders.
What are props? +
Why not a boolean per panel?Three separate true/false flags can represent “all three open,” a state your requirement says can never happen. One openIndex makes that impossible by construction. That reasoning is the answer interviewers want.
Build in three passes
PassDo thisWhy
1Render titles + content from an items array with map.Reuse the list skill.
2Add openIndex; show a panel only when openIndex === index.Conditional rendering from state.
3Click the open panel again to close it.Toggle logic.
Predict

Panel 1 is open (openIndex = 1). You click panel 1’s heading again. Then you click panel 2. What is openIndex after each click?

Reveal after you guess

Click panel 1 again → null (same index closes it). Then click panel 2 → 2 (different index opens it, replacing whatever was open). One line captures both: setOpenIndex(cur => cur === index ? null : index).

Accessibility habits (keep pass 1 simple) Rescue ladder
Rescue 1 Question

What single value can represent “none open” and “exactly one open” and nothing illegal?

Rescue 2 Concept

Keep one openIndex (start null). For each item, compute isOpen = openIndex === index. Clicking toggles between that index and null.

Rescue 3 Pseudocode
state: openIndex = null toggle(index): openIndex = (openIndex === index ? null : index) render each item: isOpen = openIndex === index button toggles this index if isOpen, show the panel
Rescue 4 Fragment
const [openIndex, setOpenIndex] = useState(null); function toggleItem(index) { setOpenIndex(cur => (cur === index ? null : index)); } // inside map: const isOpen = openIndex === index;
Rescue 5 Full reference — last resort
import { useState } from 'react'; const items = [ { title: 'What is React?', content: 'A library for building user interfaces.' }, { title: 'What is state?', content: 'Data a component remembers between renders.' }, { title: 'What are props?', content: 'Values passed from a parent to a child.' }, ]; export default function App() { const [openIndex, setOpenIndex] = useState(null); function toggleItem(index) { setOpenIndex(cur => (cur === index ? null : index)); } return ( <main> <h1>React Accordion</h1> {items.map((item, index) => { const isOpen = openIndex === index; const panelId = `panel-${index}`; return ( <section key={item.title}> <h2> <button type="button" aria-expanded={isOpen} aria-controls={panelId} onClick={() => toggleItem(index)}> {item.title} </button> </h2> {isOpen && ( <div id={panelId}><p>{item.content}</p></div> )} </section> ); })} </main> ); }
Conditional rendering{isOpen && (…)} renders the panel only when isOpen is true. When it’s false, React renders nothing there.
Done when

Single-open works, clicking the open panel closes it, and you can defend “one openIndex” over “three booleans” in a sentence.

Accordion Boss

Rebuild + change the requirement

When the requirement changes, the best state shape changes with it.

⏱️ 15-minute blank rebuild
Cold rebuild Interview mutations — change one requirement, don’t restart
MutationSkill
Click again to closeToggle back to null.
Use ids, not indexesStable identity for reorderable data.
Keyboard / focus polishReal button semantics + aria.

Try each yourself first; open a solution only to check.

Click to close Solution
setOpenIndex(cur => cur === index ? null : index);
Use ids Solution
const [openId, setOpenId] = useState(null); // inside map: const isOpen = openId === item.id; onClick={() => setOpenId(cur => cur === item.id ? null : item.id)}
Keyboard Solution
<button type="button" aria-expanded={isOpen} aria-controls={panelId} onClick={() => toggleItem(index)}> {item.title} </button>
Bug hunt — find the one thing wrong
Bug 1 Three panels can all be open at once
Show the buggy code
const [openReact, setOpenReact] = useState(false); const [openState, setOpenState] = useState(false); const [openProps, setOpenProps] = useState(false);
Show the fixed code
const [openIndex, setOpenIndex] = useState(null);

Three separate booleans can represent “all three open,” a state the requirement forbids. One openIndex (a number or null) makes that impossible by construction.

Bug 2 Multi-open version doesn’t re-render
Show the buggy code
openIds.add(id); setOpenIds(openIds);
Show the fixed code
setOpenIds(cur => { const next = new Set(cur); next.has(id) ? next.delete(id) : next.add(id); return next; });

Mutating the same Set and setting it back hands React the same reference, so it skips the render. Build a new Set.

Bug 3 Headings don’t work with the keyboard
Show the buggy code
<div onClick={() => toggleItem(index)}>{item.title}</div>
Show the fixed code
<button type="button" onClick={() => toggleItem(index)}> {item.title} </button>

A <div> can’t be focused or activated with Enter / Space. A real <button> gives you keyboard support for free.

State-design challengeNow allow multiple panels open at once. A single openIndex is no longer enough: the requirement changed, so the shape must too. Switch to a Set of open ids; a panel is open if the Set contains its id. Reshaping the state when the requirement moves is the instinct these problems are really testing.
Hint for the multi-open shape
const [openIds, setOpenIds] = useState(() => new Set()); function toggle(id) { setOpenIds(cur => { const next = new Set(cur); // new Set, not mutate next.has(id) ? next.delete(id) : next.add(id); return next; }); } // isOpen = openIds.has(item.id)
Final clean accordion
Solution Full multi-open accordion (the changed requirement)
import { useState } from 'react'; const items = [ { id: 'react', title: 'What is React?', content: 'A library for building UIs.' }, { id: 'state', title: 'What is state?', content: 'Data a component remembers.' }, { id: 'props', title: 'What are props?', content: 'Values passed from a parent.' }, ]; export default function App() { const [openIds, setOpenIds] = useState(() => new Set()); function toggle(id) { setOpenIds(cur => { const next = new Set(cur); // new Set, never mutate next.has(id) ? next.delete(id) : next.add(id); return next; }); } return ( <main> <h1>FAQ (multi-open)</h1> {items.map(item => { const isOpen = openIds.has(item.id); const panelId = `panel-${item.id}`; return ( <section key={item.id}> <h2> <button type="button" aria-expanded={isOpen} aria-controls={panelId} onClick={() => toggle(item.id)}> {item.title} </button> </h2> {isOpen && ( <div id={panelId}><p>{item.content}</p></div> )} </section> ); })} </main> ); }
How solid is Accordion + state modeling?
Accordion mastery check
React Checkpoint

The six questions to ask before any React problem

In an interview, don’t reach for syntax first. Reach for these. They turn a blank screen into a plan.

#Ask yourselfReact idea
1What must the UI remember?state
2What comes from a parent?props
3What can I compute instead of store?derived value
4What can the user do?events
5What repeats?array + map + stable key
6What appears only sometimes?conditional rendering

Can I recognize the seven core moves?

One-week retrieval schedule

Spread over the week, cold. Retrieval, not re-reading, is what sticks before an interview.

DayDo thisTime
MonCounter from a blank file, then one mutation.10 min
TueTodo add + render. Stop before delete if time runs out.20 min
WedAccordion, narrating the state-shape decision.20 min
ThuTodo full CRUD, no notes for the first 15 min.30 min
FriRandom one of the three + one changed requirement.30 min

Say-it-out-loud drills

Guess before you open each one. These are the exact questions behind the interview stumbles.

Why not let count = 0?

Changing a plain variable doesn’t persist across renders or schedule a re-render. State does both.

Why the functional setCount(c => c+1)?

When the next value depends on the previous one, the updater receives the latest queued value instead of a stale snapshot.

What makes an input controlled?

Its displayed value comes from React state, and onChange updates that state. Both halves.

Why not todos.push()?

State is treated as read-only. Mutating and re-setting the same reference can skip a render. Build a new array.

Why does each list item need a key?

React uses the key as stable identity across renders so it updates the right item. Prefer a real id over the index.

Why openIndex instead of three booleans?

It represents exactly the allowed states (none or one open) and makes “all open” impossible by construction.

What happens when a setter runs?

React queues an update and later re-renders with a new state snapshot. The setter doesn’t change the current render’s variables.

Progress Map

Your one-page map

Green = your target path (set your status at each Boss and these light up). Amber & purple come after the checkpoint passes.

Mission 0–1
Setup
Node · Vite · Hello React
Project 1
Counter
state · events
Project 2
Todo
lists · forms · CRUD
Project 3
Accordion
conditional · state shape
Last layer
TypeScript
convert what you already know
Next

TypeScript — only after the checkpoint passes

Readiness gateCounter in under 10 min from blank • Todo CRUD without copying the array patterns • Accordion with the right state shape and narration • you can spot a missing handler, a mutation bug, and a key bug with light prompting.

Then convert, don’t re-teach. Take a component you already understand and add types to its props, its state objects, and its handlers. TypeScript becomes a thin layer over React you already know, not a second mountain to climb at the same time.

type Todo = { id: string; text: string; done: boolean; }; type AccordionItem = { id: string; title: string; content: string; };
The next rung after thatWhen these feel automatic: modal, form validation, API loading / error states, search & autocomplete. Then the TypeScript conversion. Keep resisting Redux, Next.js, and the rest until an interview actually asks.
Official setup references
React from scratch — react.dev/learn/build-a-react-app-from-scratch
Vite guide & Node compatibility — vite.dev/guide
Node LTS downloads — nodejs.org/en/download
Progress and status pills are saved only in this browser, on this device.