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

12. Refs, Effects, and Persistent Learning Data
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 finished Part II with a genuinely personal app — shared favorites, a learning plan with typed statuses, a reducer guarding the rules. Then you pressed reload, and the app forgot you existed.

That’s because everything you’ve built lives inside React’s world: state, props, renders. The browser around it — its storage, its focus system, its keyboard — is outside that world, and Part III is about crossing the border safely. This chapter introduces the two crossing tools: refs, for values and DOM elements React remembers without rendering, and effects, for keeping outside systems in sync with your state.

By the end, favorites and the learning plan will survive reloads — even corrupted storage won’t crash the app — and a / keystroke will jump focus to search from anywhere on the page. Just as important, you’ll learn when not to reach for an effect, and how to read the two classic effect failures.

Refs: A Handle on the Real Page

Sometimes you need to talk to an actual DOM element — focus it, scroll to it, measure it. State can’t help: It describes what to render, not the rendered thing itself. For that, React provides useRef.

A ref is a little box with one property, current, that React preserves across renders — and, crucially, changing it never causes a render. Attach a ref to a JSX element, and after commit, current holds the real DOM node.

The search input is about to become reachable from anywhere, so it needs a ref — but App owns the shortcut plans while SearchBar owns the input. The answer is a prop. In src/components/SearchBar.tsx, extend the top of the file:

import type { Ref } from 'react'

type SearchBarProps = {
  query: string
  onQueryChange: (query: string) => void
  inputRef: Ref<HTMLInputElement>
}

Ref<HTMLInputElement> is React’s type for “a ref that can hold an input element” — the ref rides in as an ordinary prop. Wrap the destructuring across lines to fit the new arrival:

function SearchBar({
  query,
  onQueryChange,
  inputRef,
}: SearchBarProps) {

Three props now — value, callback and handle. Attach the handle to the input with the special ref attribute, above id:

ref={inputRef}

Whoever owns this ref now holds a handle to this exact DOM node. Create that owner. In src/App.tsx, extend the React import:

import { useReducer, useRef, useState } from 'react'

Just useRef joining the roster for now. Create the ref below the query state:

const searchInputRef = useRef<HTMLInputElement>(null)

It starts as null — no DOM exists during the first render — and React fills in current once the input is on the page. Add a handler that uses it, above the emptyMessage block:

function handleFocusSearch() {
  const input = searchInputRef.current
  if (input !== null) {
    input.focus()
  }
}

The null check is the ref ritual: TypeScript knows current might be empty, so you look before you leap — the same guard-then-use move as with find. Now wire both ends in the JSX. The search bar gains the ref, and a button gains the handler:

<SearchBar
  query={query}
  onQueryChange={setQuery}
  inputRef={searchInputRef}
/>
<Button
  label="Focus the search"
  variant="ghost"
  onClick={handleFocusSearch}
/>

The same ref object flows to both customers: The search bar attaches it, the button’s handler consumes it. One import is still missing — App has never rendered a Button directly. Add it above the CourseList import:

import Button from './components/Button.tsx'

Your own component, finally used by the top of the tree. Save, then click the new ghost button:

One click, and the focus ring lands in the search box — a real DOM call, made through a ref.
One click, and the focus ring lands in the search box — a real DOM call, made through a ref.

The cursor lands in the search box, focus ring and all. That’s input.focus() — a plain DOM method — invoked on a node React handed you through the ref.

Note: In React 19, function components can also accept the reserved prop name ref directly, no special machinery required. This book uses an explicitly named prop like inputRef because it works identically, types cleanly and says what it holds.

Effects: Synchronizing With the Outside

Now for the reload problem. The plan: Every time favoriteIds or learningPlan changes, write it to the browser’s localStorage; when the app starts, read it back. Here’s the round trip you’re building:

Two one-way bridges: an effect saves state out; a validated initializer loads it back in.
Bgo eva-fay zvebjeh: ev ancebx lupiw qdexe uav; u rejobojog uwivuivasel wouvn eg yegl on.

import { useEffect, useReducer, useRef, useState } from 'react'
useEffect(() => {
  localStorage.setItem(
    'learning-tracker-favorites',
    JSON.stringify(favoriteIds),
  )
}, [favoriteIds])

useEffect(() => {
  localStorage.setItem(
    'learning-tracker-plan',
    JSON.stringify(learningPlan),
  )
}, [learningPlan])

Loading Stored Data Without Trusting It

Storage is outside your type system’s jurisdiction: Whatever comes back is a stranger until proven otherwise. Create src/storage.ts and start with the favorites loader:

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

export function loadFavoriteIds(fallback: string[]): string[] {
  const stored = localStorage.getItem(
    'learning-tracker-favorites',
  )
  if (stored === null) {
    return fallback
  }
  try {
    const parsed: unknown = JSON.parse(stored)
    if (
      Array.isArray(parsed) &&
      parsed.every((entry) => typeof entry === 'string')
    ) {
      return parsed
    }
  } catch {
    // Stored text wasn't valid JSON — fall back below.
  }
  return fallback
}
export function loadLearningPlan(): LearningItem[] {
  const stored = localStorage.getItem('learning-tracker-plan')
  if (stored === null) {
    return []
  }
  try {
    const parsed: unknown = JSON.parse(stored)
    if (!Array.isArray(parsed)) {
      return []
    }
    const items: LearningItem[] = []
    for (const entry of parsed) {
      if (
        typeof entry === 'object' &&
        entry !== null &&
        'courseId' in entry &&
        'status' in entry &&
        typeof entry.courseId === 'string' &&
        (entry.status === 'Planned' ||
          entry.status === 'In Progress' ||
          entry.status === 'Completed')
      ) {
        items.push({
          courseId: entry.courseId,
          status: entry.status,
        })
      }
    }
    return items
  } catch {
    return []
  }
}
import {
  loadFavoriteIds,
  loadLearningPlan,
} from './storage.ts'
const [favoriteIds, setFavoriteIds] = useState(() =>
  loadFavoriteIds(initialFavoriteIds),
)
const [learningPlan, dispatch] = useReducer(
  learningPlanReducer,
  undefined,
  loadLearningPlan,
)
After a full reload: one favorite, one planned, one in progress. The app finally remembers.
Apqul a taql jubauw: uce hasehogi, apo lfenyix, ixe ud lzibtuzh. Ppa ixc wahoppb taxaxmehj.

Persisting What You Created

One trapdoor remains. Add a personal course, favorite it, plan it — then reload. The course vanishes (it was only state), but its id lingers in your persisted favorites and plan: a count that’s one too high, a plan row that can’t render. Orphaned references, the Chapter 11 disease in persistent form.

export function loadPersonalCourses(): Course[] {
  const stored = localStorage.getItem(
    'learning-tracker-personal',
  )
  if (stored === null) {
    return []
  }
  try {
    const parsed: unknown = JSON.parse(stored)
    if (!Array.isArray(parsed)) {
      return []
    }
    const courses: Course[] = []
    for (const entry of parsed) {
      if (
        typeof entry === 'object' &&
        entry !== null &&
        'id' in entry &&
        'title' in entry &&
        'description' in entry &&
        'category' in entry &&
        'level' in entry &&
        typeof entry.id === 'string' &&
        typeof entry.title === 'string' &&
        typeof entry.description === 'string' &&
        (entry.category === 'Frontend' ||
          entry.category === 'Languages' ||
          entry.category === 'Design') &&
        (entry.level === 'Beginner' ||
          entry.level === 'Intermediate' ||
          entry.level === 'Advanced')
      ) {
        const course: Course = {
          id: entry.id,
          title: entry.title,
          description: entry.description,
          category: entry.category,
          level: entry.level,
          isPersonal: true,
        }
        if (
          'durationHours' in entry &&
          typeof entry.durationHours === 'number'
        ) {
          course.durationHours = entry.durationHours
        }
        courses.push(course)
      }
    }
    return courses
  } catch {
    return []
  }
}
import type { Course } from './types/course.ts'
const [personalCourses, setPersonalCourses] = useState(
  loadPersonalCourses,
)
const courses = [...initialCourses, ...personalCourses]
import {
  loadFavoriteIds,
  loadLearningPlan,
  loadPersonalCourses,
} from './storage.ts'
useEffect(() => {
  localStorage.setItem(
    'learning-tracker-personal',
    JSON.stringify(personalCourses),
  )
}, [personalCourses])

Adding the Slash Shortcut

The focus button works, but power users expect a keyboard: Press /, land in search — from anywhere. A keystroke listener on the whole window is another outside-world contract, so it’s another effect. In src/App.tsx, add below the two storage effects:

useEffect(() => {
  function handleKeyDown(event: KeyboardEvent) {
    const target = event.target
    if (
      target instanceof HTMLInputElement ||
      target instanceof HTMLTextAreaElement
    ) {
      return
    }
    if (event.key === '/') {
      event.preventDefault()
      const input = searchInputRef.current
      if (input !== null) {
        input.focus()
      }
    }
  }

  window.addEventListener('keydown', handleKeyDown)
  return () => {
    window.removeEventListener('keydown', handleKeyDown)
  }
}, [])
One keystroke from anywhere: focus lands in the search box.
Age hemkfceva shih acrgdesi: feron qaghm ab pxu xuimwy huq.

Strict Mode’s Effect Rehearsal

Cleanup functions are easy to skip and hard to miss — until they bite. React’s development mode makes them bite immediately instead. Run the experiment: In the shortcut effect, add a log at the top of handleKeyDown:

console.log('keydown seen')
One keypress, two log lines — the signature of a leaked listener.
Aja qamfyewl, rbo tah fasub — vme nihwutufe oz i ziitik mawviguv.

Development Strict Mode rehearses your cleanup once; skip the cleanup, and the rehearsal leaves a zombie listener behind.
Nibubuwwedr Szpilp Sori cajuaqpiw roer htaewal ogqi; gfiz bxa nriilon, afs lke disiiwrid hairek u citkae qepfacuh vufuzq.

The Effects You Shouldn’t Write

useEffect attracts misuse like honey attracts bears, so meet the two classic mistakes on purpose. First, the unnecessary effect. It’s tempting to “sync” a derived value with state, like this — read, don’t type it:

const [favoriteCount, setFavoriteCount] = useState(0)

useEffect(() => {
  setFavoriteCount(favoriteIds.length)
}, [favoriteIds])
const [ticks, setTicks] = useState<number[]>([])

useEffect(() => {
  setTicks([...ticks, Date.now()])
})
The self-feeding cycle: an undeclared-dependency effect that sets state runs after every render it causes.
Rdo xogj-viijecz gbvda: ux usqerzehug-macigbakrk ahlugf dzow kuqx qbaxo kuyb ujbis uhind soqtuh iv moaseq.

React detects the spiral and pulls the plug, naming both usual suspects.
Siijt kizumlq qto tvibod ekl qidkm qco xfad, teduly colf uwuic quycuwbg.

Challenge: Persist One More Preference

The search query still resets on reload. Persist it: typing “css”, reloading and finding “css” still in the box — and still filtering.

Key Points

  • A ref is a render-proof box: useRef(null) plus a ref attribute yields the real DOM node in .current after commit — always null-check before use.
  • Changing a ref never re-renders; refs are for imperative conversations — focus, scroll, measure — not for driving UI.
  • An effect synchronizes React state with an outside system after commit; its dependency array must honestly list what it reads.
  • localStorage speaks only strings: Stringify out, parse in — and parsing untrusted text belongs inside try/catch.
  • Type parsed data as unknown and prove its shape — Array.isArray, typeof, in, legal-value checks — rebuilding items instead of asserting.
  • Lazy initializers — a function passed to useState, or useReducer’s third argument — seed state at mount instead of running on every render; keep them pure.
  • Every subscription effect returns a cleanup that mirrors its setup; Strict Mode rehearses the pair in development to expose missing cleanups.
  • Don’t write effects for derivable values — compute them in render — and read “Maximum update depth exceeded” as “an effect is setting state every render.”

Where to Go From Here?

The Learning Tracker now has a memory — favorites, plans and progress that outlive the tab — and you’ve crossed React’s border in both directions without smuggling in bugs: refs for the DOM, effects with honest dependencies and mirrored cleanups for everything else.

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