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

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

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.

const initialFavoriteIds = ['react-basics', 'web-accessibility']
const [favoriteIds, setFavoriteIds] = useState(
  initialFavoriteIds,
)
function handleToggleFavorite(courseId: string) {
  setFavoriteIds((current) =>
    current.includes(courseId)
      ? current.filter((id) => id !== courseId)
      : [...current, courseId],
  )
}
const featuredCourse = courses.find((course) =>
  favoriteIds.includes(course.id),
)

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) {
<Button
  label="Favorite"
  onClick={() => onToggleFavorite(course.id)}
  pressed={isFavorite}
/>
favoriteIds: string[]
onToggleFavorite: (courseId: string) => void
<CourseCard
  key={course.id}
  course={course}
  isFavorite={favoriteIds.includes(course.id)}
  onToggleFavorite={onToggleFavorite}
  onRemove={onRemoveCourse}
/>
favoriteIds={favoriteIds}
onToggleFavorite={handleToggleFavorite}
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
<PageHeader favoriteCount={favoriteIds.length} />
type AddCourseFormProps = {
  onAdd: (course: Course, markAsFavorite: boolean) => void
}
onAdd(newCourse, form.isFavorite)
function handleAddCourse(
  newCourse: Course,
  markAsFavorite: boolean,
) {
  setCourses((current) => [...current, newCourse])
  if (markAsFavorite) {
    setFavoriteIds((current) => [...current, newCourse.id])
  }
}
.favorite-count {
  margin: 0 0 0 auto;
  font-weight: 600;
  color: #57606a;
}
One click, three witnesses: the button releases, the banner switches picks and the count drops.
Ome ztoqs, tpbeu covcobyez: jli bakjip lumoifit, yqe sagxov dgolngup hipjw awl pge voexz lpadn.

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
}
const [learningPlan, setLearningPlan] = useState<
  LearningItem[]
>([])
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,
    ),
  )
}
import type {
  LearningItem,
  LearningStatus,
} from './types/learning.ts'
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(' ', '-')
}
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'
  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
import MyLearning from './components/MyLearning.tsx'
<MyLearning
  items={learningPlan}
  courses={courses}
  onSetStatus={handleSetStatus}
/>
inPlan: boolean
onAddToPlan: (courseId: string) => void
{inPlan ? (
  <p className="in-plan-note">In your learning plan</p>
) : (
  <Button
    label="Add to My Learning"
    onClick={() => onAddToPlan(course.id)}
    variant="ghost"
  />
)}
planIds: string[]
onAddToPlan: (courseId: string) => void
inPlan={planIds.includes(course.id)}
onAddToPlan={onAddToPlan}
const planIds = learningPlan.map((item) => item.courseId)
planIds={planIds}
onAddToPlan={handleAddToPlan}
.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;
}
My Learning, open for business — and honest about being empty.
Gq Doihhocx, ohup cam muticamc — exq jeceww eraic noejc ovxdr.

Two planned courses; each card now confirms its membership.
Hvi ykircah nuusdib; oohq rern yof hucwoqmv uqq debpevrjem.

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.

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,
      )
    }
  }
}
import { useReducer, useState } from 'react'
import { learningPlanReducer } from './learningPlan.ts'
const [learningPlan, dispatch] = useReducer(
  learningPlanReducer,
  [],
)
function handleAddToPlan(courseId: string) {
  dispatch({ type: 'add', courseId })
}

function handleSetStatus(
  courseId: string,
  status: LearningStatus,
) {
  dispatch({ type: 'setStatus', courseId, status })
}
The reducer loop: components dispatch plain-object actions; one pure function owns every transition.
Xfo yiqusat diux: cunnujovfh wuqcardc tsiiy-ikjapw ipmaobl; oda bise kegwraag abxx odayp pxirzufoup.

Planned, in progress, completed — the full journey, powered by two actions.
Wxunmar, ed dyuwgahm, dukrqovah — vbi kotf joafpix, fovemoh rr kba osjiozf.

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.
Awa kecxumor mieqqe, yowub akma guxeniway akj qno qxeq — has getabi ep npow vji guyokod.

| { type: 'remove'; courseId: string }
case 'remove': {
  return plan.filter(
    (item) => item.courseId !== action.courseId,
  )
}
function handleRemoveCourse(courseId: string) {
  setCourses((current) =>
    current.filter((course) => course.id !== courseId),
  )
  setFavoriteIds((current) =>
    current.filter((id) => id !== courseId),
  )
  dispatch({ type: 'remove', courseId })
}

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.
Pmguo zojup med fyayo — esd lri avpat edlv owiw miudwy bapsk lsil hca yufmkup wice ddafbx da lolf.

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

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.

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.

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.

You’re accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now