Chapters

Hide chapters

React Apprentice

First Edition · web · React 8.0.0 · Visual Studio Code

Section I: Rendering Right

Section 1: 7 chapters
Show chapters Hide chapters

11. Designing State & Building My Learning
Written by Eli Ganim

In the previous chapter, you mastered how state updates. This chapter tackles the other half of the craft: where state should live. It’s the question behind the crack you’ve been stepping over since Chapter 8 — cards that know their favorite status while the header, the banner and every other component stay ignorant.

The fix is one of React’s fundamental moves: lifting state up to the closest component that all interested parties share, then passing data down and actions up. You’ll rebuild favorites that way, and the whole page will finally agree on what’s favorited.

Then comes the payoff feature, the one the app is named for: My Learning — a plan that tracks courses from planned through in progress to completed. Its state has enough moving parts to earn your first reducer, React’s tool for state whose updates deserve a dedicated, testable home.

The Trouble With Private State

Diagnose before operating. Each CourseCard owns isFavorite via useState, which was the right first step — and its limits are now everywhere.

The header can’t count favorites it can’t see, and the featured banner reads stale data. Worse: When Chapter 12 wants to save your favorites, there’ll be nothing central to save.

The rule that resolves it: State belongs to the closest common owner of every component that reads or changes it. Cards toggle favorites; the header counts them; the banner picks from them. Their closest shared parent is App — so that’s where favorites move.

After the lift: App owns the facts; children display them and request changes through callbacks.
After the lift: App owns the facts; children display them and request changes through callbacks.

Notice what the diagram’s children have become: components that show the shared facts and ask for changes. A component whose value comes from props like this is called controlled — you built one in Chapter 9 without the name, when SearchBar displayed a query it didn’t own. Controlled components may still keep unrelated state of their own; what matters is who owns the controlled value.

Lifting the Favorites

Start at the source of truth. Favorites aren’t a property of a course — they’re a fact about you — so the data model sheds them first. In src/types/course.ts, delete the isFavorite: boolean line from Course.

TypeScript immediately lists the fallout, and it’s your worklist for the next few sections: The data file, App‘s featured lookup, the card’s state initializer and the form all mention a property that no longer exists. Expect red until each step is done — every error below is one you’re about to fix, in order. Start with the data: In src/data/courses.ts, delete all four isFavorite lines. Then give App the new state. In src/App.tsx, add above the App function:

const initialFavoriteIds = ['react-basics', 'web-accessibility']

A plain constant naming the same two courses the old data favorited, so nothing changes for the user. Then inside App, below the courses state:

const [favoriteIds, setFavoriteIds] = useState(
  initialFavoriteIds,
)

Favorites are now a list of course ids — the same two courses that were favorited in the old data, so nothing changes for the user. Ids, not copies of courses: The catalog stays the single source for course data, and this list stays the single source for taste.

Add the toggle handler below handleRemoveCourse:

function handleToggleFavorite(courseId: string) {
  setFavoriteIds((current) =>
    current.includes(courseId)
      ? current.filter((id) => id !== courseId)
      : [...current, courseId],
  )
}

Chapter 10’s toolkit, verbatim: filter removes the id if present, spread appends it if not, and a functional updater wraps the choice. Next, rewire the two derived values that used the old data. The featured course becomes:

const featuredCourse = courses.find((course) =>
  favoriteIds.includes(course.id),
)

The first course that’s in your favorites — meaning the banner will now respond to clicks. That’s the whole point of the lift, about to become visible.

Controlling the Card

CourseCard goes from state owner to state displayer. In src/components/CourseCard.tsx, delete the useState import line, and replace the props type and function opening with:

type CourseCardProps = {
  course: Course
  isFavorite: boolean
  onToggleFavorite: (courseId: string) => void
  onRemove: (courseId: string) => void
}

function CourseCard({
  course,
  isFavorite,
  onToggleFavorite,
  onRemove,
}: CourseCardProps) {

Then delete the const [isFavorite, setIsFavorite] line and the whole handleFavoriteClick function — the card no longer decides anything. Update the Favorite button to ask instead:

<Button
  label="Favorite"
  onClick={() => onToggleFavorite(course.id)}
  pressed={isFavorite}
/>

The isFavorite && favorite-note condition still compiles — same name, now a prop. The card reads identically to a user; architecturally it’s a different species: pure input-to-output, no memory.

Thread the connection through the list. In src/components/CourseList.tsx, add to the props type:

favoriteIds: string[]
onToggleFavorite: (courseId: string) => void

The list carries the whole array down and the callback back up — a pure middleman. Add both names to the destructuring, and extend the card in the map:

<CourseCard
  key={course.id}
  course={course}
  isFavorite={favoriteIds.includes(course.id)}
  onToggleFavorite={onToggleFavorite}
  onRemove={onRemoveCourse}
/>

The list translates between worlds: It holds the whole favoriteIds array, and each card receives just its own boolean answer. Back in src/App.tsx, pass the new props to CourseList:

favoriteIds={favoriteIds}
onToggleFavorite={handleToggleFavorite}

State and its updater, traveling as a pair — you’ll see this duo on props constantly from now on. Two loose ends remain, both improvements. First, the header finally gets its count. In src/components/PageHeader.tsx, replace the file’s contents:

import logo from '../assets/logo.svg'

type PageHeaderProps = {
  favoriteCount: number
}

function PageHeader({ favoriteCount }: PageHeaderProps) {
  return (
    <header className="page-header">
      <img src={logo} alt="" className="logo" />
      <div>
        <h1>Learning Tracker</h1>
        <p>Your course catalog starts here.</p>
      </div>
      <p className="favorite-count">
        Favorites: {favoriteCount}
      </p>
    </header>
  )
}

export default PageHeader

The header that couldn’t know now simply receives. Pass the count in src/App.tsx:

<PageHeader favoriteCount={favoriteIds.length} />

Second, the form. Its checkbox used to write isFavorite into the course object; now favoriting is App’s business, so the form reports the wish alongside the course. In src/components/AddCourseForm.tsx, change the props type:

type AddCourseFormProps = {
  onAdd: (course: Course, markAsFavorite: boolean) => void
}

Callbacks can carry as many arguments as the conversation needs — here, the course plus the user’s wish. In handleSubmit, delete the isFavorite: form.isFavorite, line from newCourse and change the onAdd call:

onAdd(newCourse, form.isFavorite)

The checkbox still runs the show — its value just rides the callback now instead of hiding inside the course. In src/App.tsx, teach the handler its second argument:

function handleAddCourse(
  newCourse: Course,
  markAsFavorite: boolean,
) {
  setCourses((current) => [...current, newCourse])
  if (markAsFavorite) {
    setFavoriteIds((current) => [...current, newCourse.id])
  }
}

Two setters, one event — batched into one render, as Chapter 10 taught. Add the header’s new style at the bottom of src/App.css:

.favorite-count {
  margin: 0 0 0 auto;
  font-weight: 600;
  color: #57606a;
}

The auto left margin pushes the count to the header’s far edge — the flexbox trick from Chapter 6’s buttons, rotated sideways. Save everything, then put the lift to the test: Click React Basics’ Favorite button off:

One click, three witnesses: the button releases, the banner switches picks and the count drops.
One click, three witnesses: the button releases, the banner switches picks and the count drops.

The banner now reads “Featured: Web Accessibility” and the header count says 1. In Chapter 8, that click changed one card and fooled nobody else; now every component watching favorites reacts together, because there’s exactly one fact to watch.

Building My Learning

Time for the marquee feature. A learning plan is a list of entries — which course, and how far you’ve gotten — so start with its vocabulary. Create src/types/learning.ts:

export type LearningStatus =
  | 'Planned'
  | 'In Progress'
  | 'Completed'

export type LearningItem = {
  courseId: string
  status: LearningStatus
}

A three-stop journey as a union, and an item that references a course by id — the same borrow-don’t-copy discipline as favoriteIds. Now give App the state and handlers, useState-style first. In src/App.tsx, add below the favoriteIds state:

const [learningPlan, setLearningPlan] = useState<
  LearningItem[]
>([])

Add both handlers below handleAddCourse:

function handleAddToPlan(courseId: string) {
  setLearningPlan((current) => {
    const alreadyInPlan = current.some(
      (item) => item.courseId === courseId,
    )
    if (alreadyInPlan) {
      return current
    }
    return [...current, { courseId, status: 'Planned' }]
  })
}

function handleSetStatus(
  courseId: string,
  status: LearningStatus,
) {
  setLearningPlan((current) =>
    current.map((item) =>
      item.courseId === courseId
        ? { ...item, status }
        : item,
    ),
  )
}

Adding guards against duplicates with some — an array method answering “does any item pass this test?” — and every new entry starts life 'Planned'. Status changes are Chapter 10’s map-replace, now earning its keep. Note the object shorthand in both: { courseId, status } means { courseId: courseId, status: status } — when variable and property share a name, JavaScript lets you say it once.

Both new names come from the learning types, so add the import below the course types import:

import type {
  LearningItem,
  LearningStatus,
} from './types/learning.ts'

With the vocabulary imported, the errors clear. Now the display. Create src/components/MyLearning.tsx:

import Button from './Button.tsx'
import EmptyState from './EmptyState.tsx'
import type { Course } from '../types/course.ts'
import type {
  LearningItem,
  LearningStatus,
} from '../types/learning.ts'

type MyLearningProps = {
  items: LearningItem[]
  courses: Course[]
  onSetStatus: (
    courseId: string,
    status: LearningStatus,
  ) => void
}

const emptyPlanMessage =
  'Your plan is empty — add a course from the catalog.'

function statusClass(status: LearningStatus) {
  return 'status-badge status-' +
    status.toLowerCase().replace(' ', '-')
}

The component takes the plan, the catalog to look titles up in, and one callback for status changes. The statusClass helper turns 'In Progress' into status-badge status-in-progress — a typed union driving class names, straight from the Chapter 6 playbook. Continue with the component:

function MyLearning({
  items,
  courses,
  onSetStatus,
}: MyLearningProps) {
  const plannedCount = items.filter(
    (item) => item.status === 'Planned',
  ).length
  const inProgressCount = items.filter(
    (item) => item.status === 'In Progress',
  ).length
  const completedCount = items.filter(
    (item) => item.status === 'Completed',
  ).length

  const progressLine =
    plannedCount + ' planned · ' +
    inProgressCount + ' in progress · ' +
    completedCount + ' completed'

Three derived counts and a derived sentence — no stored progress numbers anywhere, so nothing can drift. Then the JSX:

  return (
    <section className="my-learning" aria-label="My Learning">
      <h2>My Learning</h2>
      {items.length === 0 ? (
        <EmptyState message={emptyPlanMessage} />
      ) : (
        <>
          <p className="progress-line">{progressLine}</p>
          <ul className="learning-list">
            {items.map((item) => {
              const course = courses.find(
                (candidate) => candidate.id === item.courseId,
              )
              if (course === undefined) {
                return null
              }

              let actionButton = null
              if (item.status === 'Planned') {
                actionButton = (
                  <Button
                    label="Start course"
                    variant="ghost"
                    onClick={() =>
                      onSetStatus(item.courseId, 'In Progress')
                    }
                  />
                )
              }
              if (item.status === 'In Progress') {
                actionButton = (
                  <Button
                    label="Mark completed"
                    variant="ghost"
                    onClick={() =>
                      onSetStatus(item.courseId, 'Completed')
                    }
                  />
                )
              }

              return (
                <li
                  key={item.courseId}
                  className="learning-item"
                >
                  <span className="learning-title">
                    {course.title}
                  </span>
                  <span className={statusClass(item.status)}>
                    {item.status}
                  </span>
                  {actionButton}
                </li>
              )
            })}
          </ul>
        </>
      )}
    </section>
  )
}

export default MyLearning

Plenty of familiar machinery — a ternary for empty-versus-list, a keyed map, find with a null bail-out for the theoretical orphaned id. The fresh move is actionButton: JSX computed into a variable before the return, Chapter 4’s trick scaled up. Each status gets its own next step — “Start course” when planned, “Mark completed” when in progress, a rest when completed — so the UI itself walks the user along the journey.

Wire it up in src/App.tsx — the import:

import MyLearning from './components/MyLearning.tsx'

The last import of the chapter. Render it between the featured banner and SearchBar:

<MyLearning
  items={learningPlan}
  courses={courses}
  onSetStatus={handleSetStatus}
/>

The plan sits above the catalog — it’s the user’s own list, so it gets top billing. The cards need their on-ramp. In src/components/CourseCard.tsx, add two entries to the props type, below isFavorite:

inPlan: boolean
onAddToPlan: (courseId: string) => void

Same duo shape as favorites: a fact to display, a change to request. Add both to the destructuring, then add this between the Favorite button and the personal-remove block:

{inPlan ? (
  <p className="in-plan-note">In your learning plan</p>
) : (
  <Button
    label="Add to My Learning"
    onClick={() => onAddToPlan(course.id)}
    variant="ghost"
  />
)}

In the plan already? A quiet confirmation. Not yet? The invitation. CourseList passes it through — add to its props type:

planIds: string[]
onAddToPlan: (courseId: string) => void

CourseList stays a faithful middleman — it never inspects the plan, only relays it. Add both to the destructuring and to each card:

inPlan={planIds.includes(course.id)}
onAddToPlan={onAddToPlan}

Again the whole-list-in, single-answer-out translation. Then in src/App.tsx, derive the id list — below the featuredCourse block:

const planIds = learningPlan.map((item) => item.courseId)

And hand CourseList its two new props:

planIds={planIds}
onAddToPlan={handleAddToPlan}

That closes the loop: plan state down, plan requests up. Last, the styles — add to the bottom of src/App.css:

.my-learning {
  margin: 20px 0;
  background: #ffffff;
  border: 1px solid #d8dee6;
  border-radius: 12px;
  padding: 20px;
}

.my-learning h2 {
  margin: 0 0 8px;
  font-size: 1.2rem;
}

.progress-line {
  margin: 0 0 12px;
  font-size: 0.9rem;
  color: #57606a;
}

.learning-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.learning-item {
  display: flex;
  align-items: center;
  gap: 12px;
}

.learning-item .button {
  margin-top: 0;
}

.learning-title {
  font-weight: 600;
  min-width: 180px;
}

.status-badge {
  display: inline-block;
  border-radius: 999px;
  padding: 2px 12px;
  font-size: 0.85rem;
  font-weight: 600;
  border: 1px solid transparent;
}

.status-planned {
  background: #eceff3;
  border-color: #c3ccd6;
  color: #3d4753;
}

.status-in-progress {
  background: #e0edff;
  border-color: #a8c7f0;
  color: #1a4b8f;
}

.status-completed {
  background: #dcf5e3;
  border-color: #9fd8ae;
  color: #0f5426;
}

.in-plan-note {
  margin: 0;
  font-size: 0.9rem;
  font-weight: 600;
  color: #1a4b8f;
}

A white panel, plain list rows, and status pills in the level-badge mold — each status named in text, color as seasoning. Save and check the browser:

My Learning, open for business — and honest about being empty.
My Learning, open for business — and honest about being empty.

The section leads with its EmptyState — the component earning reuse barely a chapter after its birth. Add two courses from their cards and watch the section come alive:

Two planned courses; each card now confirms its membership.
Two planned courses; each card now confirms its membership.

The progress line counts, the rows carry their pills and buttons, and each added card now says “In your learning plan” instead of offering the button twice. The feature works — now make its state worthy of it.

Trading Setters for a Reducer

Step back and look at App’s learning-plan code. Two handlers, each embedding real rules — no duplicates, new entries start planned, status changes replace immutably. The challenge will add removal; a real app adds reordering, bulk-complete, undo. Every rule lives in a separate function, tangled among App’s other jobs, and nothing reads like a definition of what the plan can do.

React’s tool for this moment is useReducer: Move every update rule into one pure function — a reducer — and let components merely dispatch descriptions of what happened. Create src/learningPlan.ts:

import type {
  LearningItem,
  LearningStatus,
} from './types/learning.ts'

export type LearningPlanAction =
  | { type: 'add'; courseId: string }
  | {
      type: 'setStatus'
      courseId: string
      status: LearningStatus
    }

export function learningPlanReducer(
  plan: LearningItem[],
  action: LearningPlanAction,
): LearningItem[] {
  switch (action.type) {
    case 'add': {
      const alreadyInPlan = plan.some(
        (item) => item.courseId === action.courseId,
      )
      if (alreadyInPlan) {
        return plan
      }
      return [
        ...plan,
        { courseId: action.courseId, status: 'Planned' },
      ]
    }
    case 'setStatus': {
      return plan.map((item) =>
        item.courseId === action.courseId
          ? { ...item, status: action.status }
          : item,
      )
    }
  }
}

Take it in three pieces. An action is a plain object describing one thing that happened — a type tag plus whatever data that event carries. The union of action shapes is the plan’s complete public vocabulary: Exactly two sentences can be said to it today.

The reducer is a pure function: current state and an action in, next state out, no setters, no side effects — the same immutable moves as before, relocated. The switch statement — a case-per-value alternative to an if/else chain — branches on the tag, and because TypeScript knows the union covers every case, each branch knows its action’s exact shape and the function needs no fallback.

Now the swap. In src/App.tsx, change the React import:

import { useReducer, useState } from 'react'

useReducer is a hook like any other — it just holds state with a different update discipline. Import your reducer below the data import:

import { learningPlanReducer } from './learningPlan.ts'

A named export, because the file also exports the action type. Replace the learningPlan state line:

const [learningPlan, dispatch] = useReducer(
  learningPlanReducer,
  [],
)

Same shape as useState — current value plus a function — but the function is dispatch: It queues the action and, on the next render, your reducer’s answer becomes the state. If the reducer returns the existing state unchanged — like your duplicate guard does — React can skip the re-render entirely, the Object.is rule from Chapter 10 at work. The two handlers collapse into announcements:

function handleAddToPlan(courseId: string) {
  dispatch({ type: 'add', courseId })
}

function handleSetStatus(
  courseId: string,
  status: LearningStatus,
) {
  dispatch({ type: 'setStatus', courseId, status })
}

Delete the old useState-based bodies as you do — and note that LearningItem can leave the type import, since the annotation went with them. Save and check the browser: no visible change whatsoever. Same plan, same buttons, same pills — the rules just moved somewhere they can be read, tested and grown in one place.

The reducer loop: components dispatch plain-object actions; one pure function owns every transition.
The reducer loop: components dispatch plain-object actions; one pure function owns every transition.

That’s the whole architecture on one card: Components describe events, the reducer decides consequences. Run the journey to prove it — add three courses to the plan, start one, complete another:

Planned, in progress, completed — the full journey, powered by two actions.
Planned, in progress, completed — the full journey, powered by two actions.

Three rows, three statuses, and a progress line that agrees with all of them — every transition having passed through the same eleven lines of reducer.

Removing Cleanly

Before celebrating, hunt for a crack — state design bugs love the seams between features. Add a personal course through the form, tick its favorite box, add it to your plan:

One personal course, wired into favorites and the plan — now remove it from the catalog.
One personal course, wired into favorites and the plan — now remove it from the catalog.

Click its Remove from catalog button and study the wreckage: The course is gone, but the header still counts its favorite, and the plan’s numbers still include a row that no longer renders. Chapter 10’s removal deleted the course — nobody told favoriteIds or learningPlan, which still hold the dead id.

The lesson: Removing an entity means cleaning every list that references it. Two cleanups, two tools. The plan needs a new sentence in its vocabulary first — in src/learningPlan.ts, extend the action union:

| { type: 'remove'; courseId: string }

And its case, below setStatus’s:

case 'remove': {
  return plan.filter(
    (item) => item.courseId !== action.courseId,
  )
}

Growing a reducer is exactly this pleasant: one arm in the union, one case in the switch, and every dispatch site keeps compiling. Now in src/App.tsx, extend handleRemoveCourse so the whole app forgets together:

function handleRemoveCourse(courseId: string) {
  setCourses((current) =>
    current.filter((course) => course.id !== courseId),
  )
  setFavoriteIds((current) =>
    current.filter((id) => id !== courseId),
  )
  dispatch({ type: 'remove', courseId })
}

Three updates — two setters and a dispatch — batched into one render, each clearing one reference. Save, repeat the experiment, and this time the removal is total: count down, plan honest, no ghosts.

Choosing Where State Lives

You’ve now used every state home the book will teach, so here’s the field guide:

Three homes for state — and the arrow only ever points right when the simpler home starts to hurt.
Three homes for state — and the arrow only ever points right when the simpler home starts to hurt.

Local useState fits state only one component cares about — the form’s draft values, a details toggle. Keep it local as long as you can; lifting has a wiring cost you just paid.

Lifted state is for facts several components share — favorites, the catalog, the plan. Lift to the closest common owner, not automatically to the top. And useReducer earns its file when one piece of state accumulates several update rules — the moment setters sprawl, consolidate.

One last habit completes the chapter: State that describes a moment should be reset when its moment passes. Your form has a lingering moment right now — add a course, then start typing the next one, and the old “Added…” congratulation sits there going stale. Fix it in src/components/AddCourseForm.tsx with a tiny helper below the errors derivation:

function updateForm(next: CourseFormState) {
  setForm(next)
  if (result.kind === 'added') {
    setResult({ kind: 'idle' })
  }
}

Any form edit now also retires a stale success announcement. Route every field handler through it — change each of the five setForm(...) calls in the handlers and JSX to updateForm(...), leaving the two in handleSubmit alone (those set the next moment deliberately). Save, add a course, then start typing: The congratulation bows out the instant the next draft begins.

Challenge: Make Courses Removable From the Plan

Plans change. Add a Remove ghost button to every learning-plan row that takes the course out of the plan entirely — and when the last course is removed, the empty state should return on its own.

A few hints:

  • The reducer already speaks remove — the catalog cleanup needed it — so this challenge is pure wiring: no new state logic at all.
  • MyLearning needs one more callback prop, and App one more one-line handler that dispatches.
  • The empty state needs no extra work — if your JSX already branches on items.length, deriving wins again.

You’ll find a complete solution in the challenge folder of this chapter’s materials.

Key Points

  • State belongs to the closest common owner of everything that reads or changes it — lift it there, no higher.
  • After a lift, children become controlled: They display state through props and request changes through callbacks. Data flows down, actions flow up.
  • Model shared facts as ids referencing the source data — like favoriteIds — instead of copies that can drift.
  • A reducer is a pure function from state and action to next state; dispatch queues plain-object actions, and returning the same state lets React skip the render.
  • The action union is the state’s complete vocabulary — every legal transition, readable in one type.
  • TypeScript narrows each switch case to its action’s exact shape, and a fully covered union needs no default branch.
  • Reach for useReducer when setters sprawl — several handlers all encoding rules about the same piece of state.
  • Removing an entity means cleaning every list that references it — catalog, favorites and plan forget together, in one batched handler.
  • Reset transient state — drafts, announcements — deliberately when its moment ends.

Where to Go From Here?

Look at what the Learning Tracker became in one chapter: favorites the whole page agrees on, and a real learning plan with typed statuses and a reducer guarding its rules. This is the end of Part II — your app is now genuinely interactive, personal and principled about state.

It also has a heartbreaking flaw: Press reload and everything you just planned evaporates. State lives in memory, and memory doesn’t survive a refresh. Part III opens with the fix — refs, effects and localStorage — and your favorites and learning plan will finally remember you’re the one who made them.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.