Learn React by building one chat app 💬

No walls of theory. We build a tiny AI chat, one feature at a time. Each feature teaches one React idea, and each idea starts with the problem it solves and a real-world picture.

Our app is MiniChat: type a message, it appears, the AI replies token by token. We start with a single line of text on screen and end with a streaming assistant. Every section below adds the next piece.
Opened 0/15 · tap any section to expand
1

Put a message on screenComponents & JSX

🤔 Goal: show a chat bubble. In plain HTML you'd copy-paste the same markup every time you need one. Tedious and error-prone.
🌍 Think of it like
A component is a cookie cutter. Define the shape once, then stamp out as many cookies (bubbles) as you want, each filled with different data.

You write a function that returns what the screen should look like. Call it like a tag: <Bubble />. The stuff you pass in (like the text) are its props, its ingredients.

the cookie cutter, then the cookies
function Bubble({ text }) {   // text is a prop (an ingredient)
  return <div className="bubble">{text}</div>;
}
// stamp two cookies:
<Bubble text="Hi!" />
<Bubble text="How are you?" />
Hi!
How are you?

Now the words: that function is a component. The HTML-looking syntax is JSX (it secretly compiles to React.createElement calls). Props are the function's arguments.

2

Make the screen changeuseState & the re-render

🤔 Goal: a message count that goes up when you click. If you just do let count = 0; count++, the number changes in memory but the screen never updates. Why? React already drew the screen and walked away.
🌍 Think of it like
State is a whiteboard the component reads before it draws. You never repaint the wall by hand. You wipe the whiteboard (setCount) and React repaints the whole picture to match the new number.

To make React notice a change, the value has to live in state. Changing state = "hey React, redraw me." That redraw is called a re-render (React just runs your function again). Watch the box flash each time it redraws:

live · click and watch the flash (that flash = a re-render)
messages sent = 0
times React redrew this: 1
const [count, setCount] = useState(0);  // the whiteboard
setCount(count + 1);  // wipe it → React redraws
Try the grey button: setting the same value doesn't redraw. React sees nothing changed and skips it. That's a feature, not a bug.

Now the words: useState is a hook. It hands you the current value and a setter. Calling the setter schedules a re-render.

3

Capture what the user typesControlled inputs & events

🤔 Goal: read what's in the text box so we can send it. Where does the "current text" live? If the box keeps its own copy and React keeps another, they drift apart.
🌍 Think of it like
Handcuff the text box to state. The box always shows the state's value, and every keystroke updates the state. One source of truth, no drift.

You set the box's value from state, and on every keystroke (onChange) you push the new text back into state. Type below and watch state update on every letter:

live
state.draft = ""
<input
  value={draft}
  onChange={e => setDraft(e.target.value)} />

Now the words: a box wired to state is a controlled input. If instead you let the DOM keep the value and grab it later with a ref, that's uncontrolled. Controlled is the default you'll reach for.

4

Show a list of messagesLists & keys

🤔 Goal: render the whole conversation from an array. React needs to tell the bubbles apart across redraws. If it can't, editing gets attached to the wrong message.
🌍 Think of it like
Name tags at a conference. If everyone's tag just says their position in line ("#1, #2"), and someone cuts to the front, the tags now point to the wrong people. Give each person a permanent ID and React never mixes them up.

Give each item a stable key (its own id), not its array index. Here's the actual bug, live. Type in the top box, then "Add to top":

live · type "hello" up top, then click Add to top
What you just saw: with index keys your typed text stays glued to the position, so the new empty row steals it. Stable ids make the text follow its own message.
{messages.map(m => <Bubble key={m.id} text={m.text} />)}
5

Why React is fastRe-render & reconciliation

🤔 Question: if changing state redraws the whole component, isn't repainting the entire chat on every keystroke slow?
🌍 Think of it like
Spot the difference between two photos. React keeps a lightweight copy of the last screen, compares it to the new one, and only touches the real page where they actually differ. It doesn't repaint the whole wall.

"Re-render" means React re-runs your function to build a fresh description of the UI. Then it diffs that against the previous description and updates only the changed DOM nodes. A parent re-render re-runs children too, unless you tell it not to. Flash = re-rendered:

live · render the parent, watch which children repaint
Child A
props change
Child B
props stable
Child C
props change
Tick the box: React.memo tells React "skip this child if its props didn't change." Child B stops flashing.

Now the words: the in-memory description is the virtual DOM. The diff-and-update step is reconciliation. Keys (section 4) make that diff accurate.

6

Talk to the outside worlduseEffect

🤔 Goal: after the user sends a message, call the AI backend. But rendering must stay pure (just describe the UI). Where do side jobs like network calls go?
🌍 Think of it like
A chore you do after tidying the room. First React draws the screen (tidies), then it runs your "chores" (fetch data, start a timer). And every chore that sets something running needs a cleanup to stop it, like turning off the tap.

Put side-effects in useEffect. It runs after the render. Return a function to clean up. The dependency array controls when it re-runs. Mount a "typing…" timer and watch the log:

live · effect runs on mount, cleanup runs on unmount
useEffect(() => {
  const id = setInterval(tick, 1000);      // start the tap
  return () => clearInterval(id);           // cleanup: close the tap
}, [chatId]);  // re-run only when chatId changes
The 3 deps mistakes: [] = run once. Leave a value out = you read a stale old value. No array at all = runs every render (often an infinite loop).
7

Stream the AI replythe payoff: tokens as they arrive

🤔 Goal: the AI reply takes a few seconds. Waiting for the whole thing feels dead. We want words to appear as they're generated.
🌍 Think of it like
Water filling a glass. Each token that arrives gets appended to the text in state, and because state changed, React redraws, so you see the reply grow live.

Read the response as a stream and append each chunk to state. Hit send:

live · simulated token stream (this is MiniChat working)
const reader = res.body.getReader();
while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  setReply(prev => prev + decode(value));   // append token → redraw
}
This is the role: React reading an SSE / fetch stream from a Node backend-for-frontend that calls Bedrock. You just built the frontend half in your head.
8

Stop recomputing the same thinguseMemo & useCallback

🤔 Goal: we count tokens across the whole conversation on every keystroke. That math is expensive and the messages didn't even change. Wasteful.
🌍 Think of it like
Leftovers in the fridge. If the ingredients haven't changed, don't cook the dish again, reheat the saved result. useMemo saves a computed value; useCallback saves a function.

Wrap the expensive calc in useMemo with a dependency list. It only recomputes when those inputs change. Feel it, first run is slow, cached run is instant:

live · a deliberately slow calculation
click a button…
Don't sprinkle it everywhere. Memoizing has its own cost. Use it for genuinely heavy compute, or to keep a stable reference so a React.memo child (section 5) can actually skip.
9

Share the logged-in user everywhereContext API

🤔 Goal: the deep-down message input needs the current user and theme. Passing them through Layout → Sidebar → Composer → Input, none of which use them, is miserable.
🌍 Think of it like
A radio broadcast vs passing a note. Instead of handing a note person to person down the hall, the top broadcasts on a channel and anyone deep down just tunes in.

A Provider at the top holds the value; any child consumes it directly. Toggle the theme up top, the deep child updates with no props threaded:

live
Provider
dark
Layout
Sidebar
Deep input
reads: dark
Cost: every consumer redraws when the context value changes. Don't cram fast-changing state into one giant context.

The pain it removes (passing props through layers that ignore them) has a name: prop drilling.

10

Message status: sending / sent / faileduseReducer

🤔 Goal: a message moves through states and several fields change together. Scattered setX calls get tangled and buggy.
🌍 Think of it like
A vending machine. You don't reach inside and move parts. You press a labeled button (an action) and the machine's internal logic (the reducer) decides the next state.

You dispatch an action; one reducer function computes the next state in one place. Watch each action transition the value:

live
unread = 0
useState vs useReducer: reducer wins when the next state depends on the action type, or many fields change as a unit.
11

Stop copy-pasting the fetchCustom hooks

🤔 Goal: three components each do the same "fetch, store, loading" dance. Copy-paste rots.
🌍 Think of it like
A recipe card. Write the steps once on a card (useFetch), then anyone can follow it. You extract the behavior, not just markup.

Move the hooks into your own useSomething function and return what callers need. Tap to compare:

before vs after
// same fetch logic copy-pasted in every component…
const [data,setData]=useState();
useEffect(()=>{ fetch(url).then(r=>r.json()).then(setData); },[url]);
Rules of hooks: only call hooks at the top level (never in loops/ifs), only from React functions, and a custom hook's name must start with use.
12

Two components, one shared numberLifting state up

🤔 Goal: the header shows unread count and the sidebar changes it. If each keeps its own copy, they disagree.
🌍 Think of it like
One family calendar on the fridge. Don't give each person their own calendar. Put one where both parents can read and write it. In React that shared spot is the common parent.

Move the shared state up to the nearest common parent, pass the value and setter down as props. Both children now read one truth:

live · both children touch one parent state
PARENT holds count = 0
Header
Shared
0
Sidebar
When "lifting" reaches too many layers, that's your cue to switch to Context (section 9).
13

How to organize componentsComposition over inheritance

🤔 Goal: reuse a Modal for both the settings dialog and the delete-confirm, without copy-pasting or building a class hierarchy.
🌍 Think of it like
An empty picture frame. The frame (Modal) doesn't care what photo goes inside. You slot different content in through children. That's composition.
Presentational
just renders props, no state
Container
holds state + data, passes down
Composition
<Frame>{children}</Frame>
// slot any content into the reusable frame
<Modal><SettingsForm/></Modal>
<Modal><DeleteConfirm/></Modal>
14

Interview rapid-firetap a card to flip

15

Mistakes that fail code reviewanti-patterns