12.
Refs, Effects, and Persistent Learning Data
Written by Eli Ganim
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:
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
refdirectly, no special machinery required. This book uses an explicitly named prop likeinputRefbecause 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:
Writing to browser storage is a side effect on an external system — exactly what useEffect exists for. An effect is a function React runs after commit, when your state change has already reached the page, making it the right moment to tell the outside world.
In src/App.tsx, extend the React import for the new hook:
import { useEffect, useReducer, useRef, useState } from 'react'
Then add the two effects below the searchInputRef line:
useEffect(() => {
localStorage.setItem(
'learning-tracker-favorites',
JSON.stringify(favoriteIds),
)
}, [favoriteIds])
useEffect(() => {
localStorage.setItem(
'learning-tracker-plan',
JSON.stringify(learningPlan),
)
}, [learningPlan])
Each useEffect takes two arguments. First, the function to run. Second, the dependency array: the values the effect reads — when a render changes one of them, the effect runs again; otherwise React skips it. One effect per storage key keeps each save independent: Toggling a favorite rewrites favorites, not the plan.
Note:
localStoragestores only strings, so JSON.stringify converts your arrays into text like["react-basics"], and JSON.parse later converts text back into data. Parsing is the risky direction — stored text can be stale, hand-edited or broken, andJSON.parsethrows an error on invalid input. That fact shapes the loading code you’re about to write.
Saving works from this moment — if you’re curious, DevTools’ Application tab shows the keys updating as you click — but there’s nothing to verify on the page yet, because nothing reads the data back. Loading is the careful half.
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
}
Three layers of defense, top to bottom. No stored value at all — a first visit — returns the caller’s fallback. Then the risky parse runs inside try/catch: If JSON.parse throws, execution jumps to the catch block instead of crashing the app, and falls through to the fallback. Chapter 13 gives try/catch a fuller treatment; here it’s your seatbelt.
The third layer is the type-level one: parsed is annotated unknown — TypeScript’s “could be anything” type that forbids every use until you prove a shape. Array.isArray plus an every check that each entry is a string convinces the compiler, and only then does parsed pass as string[]. No assertions, no trust — proof.
The plan loader applies the same philosophy to a harder shape. Add below:
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 []
}
}
The strategy: Rebuild rather than trust. A for...of loop walks the parsed array, and each entry must prove it’s an object (the in operator checks a property exists), carries a string courseId and holds one of the three legal statuses. Entries that pass are rebuilt into fresh, typed items; imposters are silently skipped, salvaging whatever’s valid.
Now plug the loaders into startup. In src/App.tsx, import them below the reducer import:
import {
loadFavoriteIds,
loadLearningPlan,
} from './storage.ts'
Two named exports from the new module. Change the favorites state to load lazily:
const [favoriteIds, setFavoriteIds] = useState(() =>
loadFavoriteIds(initialFavoriteIds),
)
Passing a function to useState is a lazy initializer: Its result seeds the state at mount, and later renders skip the work entirely. Written as useState(loadFavoriteIds(initialFavoriteIds)), the storage read would instead run on every render with its result ignored — wasteful. One honesty note: Development Strict Mode may invoke initializers twice, its usual purity audit, so keep them as side-effect-free reads like these. The reducer offers the same option via a third argument:
const [learningPlan, dispatch] = useReducer(
learningPlanReducer,
undefined,
loadLearningPlan,
)
When useReducer receives a third argument, that function computes the initial state at mount (receiving the second argument, which loadLearningPlan ignores — hence the undefined placeholder).
Save, and put persistence through a real trial: Toggle a favorite off, add two courses to your plan, start one — then reload the page:
Everything holds. Now try to break it: This chapter’s materials include a corrupt-storage-fixture folder with a console script that plants malformed favorites and a plan full of imposters. Paste it into the console, reload, and watch the loaders shrug — defaults for the broken favorites, and only the one valid plan entry salvaged. That resilience is what the unknown-and-validate ceremony bought.
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.
The cure is symmetry: If favorites and plans survive, the courses they point at must survive too. Add one more loader at the bottom of src/storage.ts:
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 []
}
}
It’s the plan loader’s fortress with more rooms — same rebuild strategy, two new tricks. Literal-union fields like category are validated by checking against each allowed value, and the optional durationHours is copied only when present and numeric, so absence stays absence. The Course type joins the imports at the top of the file:
import type { Course } from './types/course.ts'
Add it above the LearningItem import line. Now restructure App so personal courses live apart from the built-in catalog. In src/App.tsx, replace the courses state line with:
const [personalCourses, setPersonalCourses] = useState(
loadPersonalCourses,
)
The built-in list needs no state at all anymore — it never changes. Derive the combined catalog below the searchInputRef line:
const courses = [...initialCourses, ...personalCourses]
Built-ins plus yours, merged fresh every render — the derive-don’t-store rule meeting persistence. Three references still point at the old state. In handleAddCourse and handleRemoveCourse, change setCourses to setPersonalCourses — the logic inside stays identical, since only personal courses are ever added or removed. Then extend the storage import:
import {
loadFavoriteIds,
loadLearningPlan,
loadPersonalCourses,
} from './storage.ts'
Last, the save side — one more effect, above the favorites effect:
useEffect(() => {
localStorage.setItem(
'learning-tracker-personal',
JSON.stringify(personalCourses),
)
}, [personalCourses])
The same one-key-one-effect shape as its siblings. Save, then re-run the trapdoor experiment: personal course, favorited, planned, reload. This time everything returns together — course, favorite, plan row — because everything that references persists alongside everything referenced.
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)
}
}, [])
Top to bottom: The handler first checks where the keystroke happened — instanceof asks whether the event’s target is an input or text area, and if so, bails out, so typing “css/layout” into search doesn’t teleport the cursor. A genuine / gets its default (typing the character) prevented, and the familiar ref-focus move runs.
Then the two lines that make this a proper effect. addEventListener is the setup; the returned function is the cleanup, which React runs before the effect re-runs and when the component leaves the page. Setup and cleanup must be mirror images — same event name, same function — or listeners leak.
The empty dependency array says this effect reads no state — the ref is a stable box, so [] is honest — meaning setup runs once, cleanup at the very end. Dependencies should always simply tell the truth about what the effect reads; lying to the array is how effects go stale.
Save, click somewhere neutral on the page, and press /:
Focus jumps to the search box, ring and all — the button’s trick, now from the keyboard. Then click into the search field and press / again: this time it types a slash, because of the target check.
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')
Then delete the cleanup — the whole return () => {...} block — and save. Press a key anywhere and check the console:
Two logs per keypress: Two listeners are installed. Here’s why:
When this app mounts in development, root Strict Mode runs each effect’s setup, then immediately its cleanup, then setup again — a rehearsal proving your cleanup actually undoes your setup. With no cleanup to run, the rehearsal’s second setup stacked a second listener, and the doubled log is your bug report.
Restore the return block, save, and press a key: one log. The rehearsal now passes — the extra setup’s listener gets removed by the extra cleanup. Delete the console.log too; expect no other change.
Two rules fall out. This rehearsal is development-only — production runs setup once — and it’s the effect-flavored sibling of the double render you met in Chapter 10. And the fix for a doubled effect is never “disable Strict Mode”; it’s “write the missing cleanup.”
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])
It works, and it’s wrong twice over: an extra render every change (state settles, effect fires, state changes again), and duplicate state that Chapter 10 taught you to refuse. favoriteIds.length, computed during render, does the same job with zero machinery — if a value can be derived, an effect syncing it is a bug with extra steps. Effects are for the outside world; state-to-state syncing isn’t outside.
Second, the infinite loop — this one, do try. Add temporarily to App, below the ref:
const [ticks, setTicks] = useState<number[]>([])
useEffect(() => {
setTicks([...ticks, Date.now()])
})
Save, and the console erupts. Here’s the machine you accidentally built:
Among the console noise, React’s diagnosis:
Read the anatomy: No dependency array means “run after every render,” and the effect sets state, causing a render, causing the effect… React detects the spiral and pulls the plug. When you meet this error in the wild, the checklist is exactly the message: an effect that sets state, with a missing array or a dependency that’s a fresh object every render. Delete the experiment — both the state line and the effect — and calm returns.
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.
A few hints:
- A loader in storage.ts first: Raw strings need no JSON, and
getItemreturningnullmaps neatly onto returning''. -
useState(loadQuery)— passing the function itself is the tersest lazy initializer of all. - One more effect with one honest dependency finishes the save side.
You’ll find a complete solution in the challenge folder of this chapter’s materials.
Key Points
- A ref is a render-proof box:
useRef(null)plus arefattribute yields the real DOM node in.currentafter 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.
-
localStoragespeaks only strings: Stringify out, parse in — and parsing untrusted text belongs insidetry/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, oruseReducer’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.
One border remains, and it’s the big one: the network. The catalog still materializes from a local file, instantly and infallibly — nothing like the real world of loading spinners, failed requests and retries. Chapter 13 replaces the import with an honest-to-goodness fetch, models every state a request can be in, and packages the whole workflow into your first custom hook.