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.
This loop is the whole course. You will meet it in Counter, again in Todo, again in Accordion. Learn it once.
| Type it | Never copy-paste project code. Typing is where the learning is. |
| Predict first | Every Predict box is closed on purpose. Guess out loud, then reveal. |
| Earn the rescue | Rescue levels are collapsed. Open Rescue 1 only after a real attempt. The full answer is Rescue 5, the last resort. |
| Rebuild cold | Each project ends with a blank-file rebuild. That, not “I finished the lesson,” is mastery. |
Each project ends with a self-check. Be honest. The goal is the top tier, under interview pressure.
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.
| Tool | Its one job |
|---|---|
| Node | Runs JavaScript tooling on your machine. |
| npm | Installs and runs packages. Ships with Node. |
| Vite | Creates the project and runs the dev server. |
| React | The library you are actually here to learn. |
| VS Code | The editor + a built-in terminal. |
| Browser | Where the running app appears. |
Terminal › New Terminal.node -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.Run one line at a time:
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.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:
src/App.jsx: delete the logo imports, the useState counter, and the import './App.css' line, leaving just the empty component above.src/index.css: delete everything inside it. Its default centering and dark background would otherwise make your first output look off. Keep the file itself; main.jsx still imports it.index.html: change <title>Vite + React</title> to your own (e.g. Counter), and delete the <link rel="icon" … /vite.svg /> line. Leave <div id="root"></div> and the <script> tag alone.| Symptom | Try this |
|---|---|
node: command not found | Node not installed, or terminal needs a restart. Reinstall Node LTS, reopen VS Code, retry node -v. |
npm create fails | Check 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 page | A code error. Read the browser console and the VS Code terminal before changing anything else. |
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.
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.
| File | What it is |
|---|---|
| index.html | Browser entry point. Holds <div id="root"></div>. Leave it alone. |
| src/main.jsx | Starts React by rendering <App /> into that root div. Leave it alone. |
| src/App.jsx | Your main component. Almost all your work happens here. |
| src/App.css | Styles. Optional for now; ignore it. |
Back in the now-empty src/App.jsx, make its entire contents exactly this:
Before you save: what appears in the browser? And is that line HTML?
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.
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)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.Try to return two lines with no wrapper:
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.
Your own text is on screen, edits hot-reload instantly, and you can finish the sentence: “A component is a function that…”
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 is memory. Calling the setter asks React to render again with a new value.
Before you code — answer out louduseState: what it is, and what setCount doesA 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.
That one line has three parts worth naming out loud:
| Part | What it is |
|---|---|
useState(0) | You call it once with the initial value (here 0). It hands back an array of exactly two things. |
count | The current value for this render. Read it anywhere in your JSX. You never assign to it directly. |
setCount | The 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.
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.| Pass | Do this | Why |
|---|---|---|
| 1 | Show 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. |
| 2 | Add a +1 button that calls a handler. | An event calls the setter. |
| 3 | Add −1 and Reset. | One state value drives many buttons. |
Your +1 handler runs these two lines. After one click, what is the count?
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). This same rule scales to arrays and objects in the next project.What does the UI need to remember? What event changes that memory? Which function actually changes it?
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.
setCount(c => c + 1) as: “React, take the latest count and give me the next one.”+1, −1, and Reset all work, and you can say in one sentence what useState returns.
Completion is not mastery. Rebuilding from nothing is.
App.jsx.| Mutation | Do this | Skill it drills |
|---|---|---|
| +5 | Add a button that adds 5. | Same state, new event. |
| Floor at 0 | Never let count go below 0. | Compute the next value safely. |
| “High” | Show the word High only when count ≥ 10. | Conditional rendering. |
| Step input | Add a number input; +/− move by that step. | A second, independent piece of state. |
| Live label | Button text changes based on the count. | UI derived from state. |
Try each one yourself first. Solutions are collapsed; open one only to check.
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.
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)}.
useState returns.onClick without calling it during render.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.
map + a stable key.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.
Start from a hard-coded array. No input, no state yet.
What if you used key={index} from map((todo, index) => …) instead?
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.
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?
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.
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.
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.
Typing updates text state, the input always reflects that state, and you can name both halves of a controlled input.
setStateYou 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.
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:
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.
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:
The sequence when you call a setter:
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.
One sentence: keep a single source of truth for anything that changes over time, and make the UI a pure function of it.
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.
setCount / setText?A setter accepts either a plain value or a function:
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:
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.
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.
setTodos is doingsetTodos(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.)
Wrap the input in a <form>. The minimum todo object is just three fields:
<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".delete/toggle use, not the words on screen.How do you make a new array that is “everything before, plus one more” without touching the old one?
[...current, newTodo] = keep every old todo, then add the new one.
Submitting adds one todo and clears the input, and empty / whitespace-only entries are rejected.
filter to a new, smaller array using identity.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.
filter is the right toolcurrent.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.
onClick={() => deleteTodo(todo.id)} passes a function. Writing onClick={deleteTodo(todo.id)} would call it during render, the same bug as the Counter boss.push mutates the existing array and hands React the same reference, so React often sees “no change.” Build a new array instead.
Clicking Delete removes exactly the clicked todo, even when two todos share the same text.
map + object spread to replace one item immutably.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.
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.
[...current, newTodo]
Togglemap(…) — same length, one item replaced
Deletefilter(…) — every item except one
{ ...todo, done: !todo.done } makes a new object: copy the old fields, then override done. Every non-matching todo is reused unchanged.const left = todos.filter(t => !t.done).length;. Duplicated state is how lists get out of sync.The checkbox toggles one todo immutably (via map + spread), and any “items left” count is derived, not stored.
Add + delete + render, from a blank file, narrating the array updates.
| Mutation | Do this | Skill |
|---|---|---|
| Items left | Show a live count of not-done todos. | Derived state. |
| Filters | All / Active / Done buttons. | Conditional list rendering. |
| Edit | Make a todo’s text editable. | Update one object with map. |
| Disable Add | Disable the button when input is blank. | UI state derived from existing state. |
| Clear completed | Remove all done todos at once. | Filter the array. |
Try each yourself first; open a solution only to check.
push mutates the existing array and hands React the same reference, so it often sees “no change.” Build a new array instead.
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.
Without the arrow, deleteTodo(todo.id) is called during render. Wrap it so it runs on the click.
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.
todo.id is a better key than the index.push, splice, or direct mutation.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.
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.
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:
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.| Pass | Do this | Why |
|---|---|---|
| 1 | Render titles + content from an items array with map. | Reuse the list skill. |
| 2 | Add openIndex; show a panel only when openIndex === index. | Conditional rendering from state. |
| 3 | Click the open panel again to close it. | Toggle logic. |
Panel 1 is open (openIndex = 1). You click panel 1’s heading again. Then you click panel 2. What is openIndex after each click?
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).
<button> for the clickable heading, so keyboard support comes free.aria-expanded={isOpen} on the button.aria-controls to the id of the panel it opens.What single value can represent “none open” and “exactly one open” and nothing illegal?
Keep one openIndex (start null). For each item, compute isOpen = openIndex === index. Clicking toggles between that index and null.
{isOpen && (…)} renders the panel only when isOpen is true. When it’s false, React renders nothing there.Single-open works, clicking the open panel closes it, and you can defend “one openIndex” over “three booleans” in a sentence.
When the requirement changes, the best state shape changes with it.
items array.| Mutation | Skill |
|---|---|
| Click again to close | Toggle back to null. |
| Use ids, not indexes | Stable identity for reorderable data. |
| Keyboard / focus polish | Real button semantics + aria. |
Try each yourself first; open a solution only to check.
Three separate booleans can represent “all three open,” a state the requirement forbids. One openIndex (a number or null) makes that impossible by construction.
Mutating the same Set and setting it back hands React the same reference, so it skips the render. Build a new Set.
A <div> can’t be focused or activated with Enter / Space. A real <button> gives you keyboard support for free.
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.openIndex beats three booleans for single-open.cur === index ? null : index without copying it.&&.In an interview, don’t reach for syntax first. Reach for these. They turn a blank screen into a plan.
| # | Ask yourself | React idea |
|---|---|---|
| 1 | What must the UI remember? | state |
| 2 | What comes from a parent? | props |
| 3 | What can I compute instead of store? | derived value |
| 4 | What can the user do? | events |
| 5 | What repeats? | array + map + stable key |
| 6 | What appears only sometimes? | conditional rendering |
{cond && …} or ternarySpread over the week, cold. Retrieval, not re-reading, is what sticks before an interview.
| Day | Do this | Time |
|---|---|---|
| Mon | Counter from a blank file, then one mutation. | 10 min |
| Tue | Todo add + render. Stop before delete if time runs out. | 20 min |
| Wed | Accordion, narrating the state-shape decision. | 20 min |
| Thu | Todo full CRUD, no notes for the first 15 min. | 30 min |
| Fri | Random one of the three + one changed requirement. | 30 min |
Guess before you open each one. These are the exact questions behind the interview stumbles.
let count = 0?Changing a plain variable doesn’t persist across renders or schedule a re-render. State does both.
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.
Its displayed value comes from React state, and onChange updates that state. Both halves.
todos.push()?State is treated as read-only. Mutating and re-setting the same reference can skip a render. Build a new array.
React uses the key as stable identity across renders so it updates the right item. Prefer a real id over the index.
openIndex instead of three booleans?It represents exactly the allowed states (none or one open) and makes “all open” impossible by construction.
React queues an update and later re-renders with a new state snapshot. The setter doesn’t change the current render’s variables.
Green = your target path (set your status at each Boss and these light up). Amber & purple come after the checkpoint passes.
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.