Topic 27.5
Accessibility, Performance and Tests
In one line
Milestone 5: make every action work from the keyboard, keep focus on a card when it moves, announce changes to screen readers, add memo only where render counts show wasted work, and prove the behaviour with Testing Library tests.
Think of it like this
A lift in a building. Stairs work for most people, but a building isn't finished until someone in a wheelchair can reach every floor, the buttons have braille, and a voice says "third floor" when the doors open. Then an inspector rides it and signs the certificate. Keyboard support is the lift, the live region is the voice, focus management is the doors opening on the right floor, and tests are the inspector.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Focus
- Which element receives the keyboard right now. It is shown by a focus ring.
- Focus management
- Moving focus with code to the right element after the page changes, so keyboard users don't get lost.
- tabIndex={-1}
- Lets code call
focus()on an element without adding it to the Tab order. - Ref callback
- A function given to
ref; React calls it with the DOM node when it is attached (and withnull, or a cleanup in React 19, when removed). - Live region
- An element whose text changes are read out by screen readers, like
role="status". - sr-only
- A CSS class that hides content visually but keeps it available to screen readers.
- memo
- Wraps a component so React skips re-rendering it when its props are the same as last time.
- Testing Library
- A tool for testing components the way users use them: find elements by role, label and text, then act on them.
- user-event
- The Testing Library package that simulates real typing, clicking and key presses.
Step by step
01Do a keyboard-only pass
Unplug the mouse (or don't touch it) and use the milestone 4 board with Tab, Shift+Tab, Enter and Space. Every action works, because they are buttons. But after pressing → on a card, focus vanishes: press Tab and you start again from the top. That is the bug to fix.
02Remember the card, focus it when it lands
moveTask stores the card's id before dispatching. The new <li> in the target column runs its ref callback during commit, finds its id and focuses itself.
const focusIfMoved = (node) => {
if (node && focusAfterMove === task.id) {
focusAfterMove = null; // only once
node.focus();
}
};
return (
<li className="task" tabIndex={-1} ref={focusIfMoved} onKeyDown={handleKeyDown} aria-describedby="move-help">
...
</li>
);03Announce the change
A role="status" paragraph is always rendered; moveTask puts Moved "…" to Done. into a small store that the paragraph reads. Failures already appear in a role="alert" banner.
04Measure renders, then add memo
Count renders while typing in Search (React DevTools Profiler, or a counter as in the second example). Every card re-rendered on every keystroke though none changed. memo(TaskCard) fixes it because its only prop, task, keeps the same object identity. Check again: zero card renders per keystroke.
05Write the tests
One file per behaviour area. Each test renders, acts like a user and checks what a user would notice. The same file runs in Vitest in the project.
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { test, expect } from "vitest";
import Board from "./Board.jsx";
test("Alt+ArrowRight moves the focused card and focus follows it", async () => {
const user = userEvent.setup();
render(<Board initialTasks={seed} />);
screen.getByRole("button", { name: "Move Write the tests right" }).focus();
await user.keyboard("{Alt>}{ArrowRight}{/Alt}");
const done = screen.getByRole("region", { name: "Done" });
expect(within(done).getByText("Write the tests")).toBeInTheDocument();
expect(done).toContainElement(document.activeElement);
});06Run them
Vitest prints one line per file, and the failing assertion with its message when something breaks.
$ npm run test:run
✓ src/state/tasksReducer.test.js (5 tests)
✓ src/components/Board.test.jsx (3 tests)
Test Files 2 passed (2)
Tests 8 passed (8)Try it yourself
- 1
Break the focus on purpose
In the test example, delete the
ref={...}line fromTaskCard. Predict which test fails and its message, then run. Put it back. - 2
Measure memo's limit
In the render-count example, change
TASKS.filter(...)toTASKS.map((t) => ({ ...t })).filter(...). Predict the memo counts. (Every card renders again: new objects every render defeatmemo.) - 3
Test the alert
Write a test for milestone 5's rollback idea in the test example: give
BoardanonMovethat fails, or add afailNextflag, and expectgetByRole("alert")to containCould not move.
Code & diagrams
Tab to a card's → button and press Enter, or press Alt + → / Alt + ←: the card moves and keeps focus (blue ring). A hidden `role="status"` region announces each move. Tick Server is down and move: focus follows the card back.
Preview
Console
Nothing logged yet.Each keystroke re-renders every matching plain card although no task changed; the memo cards render 0 times, because each `task` prop is the same object as before. Only measured waste like this earns a `memo`.
Preview
Console
Nothing logged yet.Expected console output
first render: { plain: 4, memo: 4 }
typed "t": { plain: 3, memo: 0 }
typed "te": { plain: 1, memo: 0 }
typed "tes": { plain: 1, memo: 0 }Real Testing Library and user-event against the board's behaviour. `getByRole("region", { name: "Done" })` works only because each column is a section labelled by its heading: the test doubles as an accessibility check.
Preview
Console
Nothing logged yet.Expected console output
✓ the reducer moves a task (a plain function test)
✓ adds a typed task to To do
✓ the move button moves the card and announces it
✓ Alt+ArrowRight moves the focused card and focus follows itBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Forget focus management
In the test example, remove the ref={(node) => ...} from the card's <li>.
Break #2
Render the live region only when there is a message
Write {message && <p role="status">{message}</p>} instead of always rendering the paragraph.
Myth vs fact
Myth
Buttons with icons like → are accessible as they are.
Fact
A screen reader reads "right-pointing arrow, button": which card? Give each icon button an aria-label naming the action and the task, such as Move Write the tests right.
Myth
Wrap every component in memo to be safe.
Fact
memo adds a props comparison to every render and does nothing when props are new objects or functions. Add it where measuring shows repeated, avoidable work, or let the React Compiler handle it.
Myth
Testing implementation details (state, reducer calls from components) gives better coverage.
Fact
Those tests break on every refactor and pass when the UI is broken. Test the reducer as a function, and components through what users see and do.
Myth
aria-live="assertive" is better because it's faster.
Fact
Assertive interrupts whatever the user is hearing. Use polite (role="status") for confirmations and keep assertive (role="alert") for errors that need immediate attention.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
React 19 ref callbacks can return a cleanup function (called on detach instead of calling the ref with
null). Here the inline ref runs on every render of that card; thefocusAfterMove === task.idcheck makes it a no-op except right after a move. With the React Compiler the callback would be memoised. - ▸
Keyboard reordering inside a column (Alt + ↑/↓) follows the same pattern: an
orderfield, areorderedaction, focus restore and an announcement such asWrite the tests, position 2 of 3. Libraries such as dnd-kit implement these announcements for drag and drop. - ▸
Profiling in development exaggerates render costs (StrictMode double renders, development checks). Confirm a memo win with the React DevTools Profiler on a production build (
vite build && vite previewwith profiling enabled) before keeping it (Topic 16.6). - ▸
Run axe in the test suite (
vitest-axeor@axe-core/playwrightin an end-to-end test) on each route. It catches regressions like an unlabeled input; pair it with one Playwright test that does the main flow with the keyboard only (Topic 17.7).
Remember this
- 1
Keyboard first (Topic 18.1 and 18.4). Everything already uses real
<button>elements, so Tab reaches every action and Enter or Space presses it: that is the baseline, and it is why the board has move buttons rather than drag-and-drop only (dragging needs a single-pointer alternative, WCAG 2.5.7). As a faster path, a card handles Alt + ← / → inonKeyDown; the key event bubbles from whichever button inside the card has focus.aria-describedbypoints the card at a visible hint, so a screen reader reads the shortcut too. - 2
Focus management (Topic 18.2). When a card moves, React removes its
<li>from one column and creates a new one in another (different parent, so a new DOM node). The button that had focus is gone, and the browser drops focus to<body>: a keyboard user is thrown back to the top of the page. The fix: before dispatching, remember the moved card's id; the card's ref callback runs when React attaches the new<li>, sees its id and callsnode.focus(). The<li>hastabIndex={-1}, which makes it focusable from code without adding a Tab stop. On rollback the same thing happens, so focus follows the card back. - 3
Live regions (Topic 18.3). Sighted users see the card jump; screen reader users need to hear it. A
role="status"element (a polite live region) is announced when its text changes, after the screen reader finishes what it's saying. It must be in the page before the text changes, so render it always and change only its text:Moved "Write the tests" to Done.Errors userole="alert"(assertive: announced at once). The region is visually hidden with ansr-onlyclass, notdisplay: none, which would hide it from screen readers too. - 4
Performance: measure, then memo (Topic 16.1, 16.3 and 16.6). Typing in Search re-renders
Board, and by default everyTaskCardwith it. The render-count example measures it: each keystroke re-rendered every matching card although none changed. Because the reducer reuses unchanged task objects and cards call the module-levelmoveTask(no new function props), wrappingTaskCardinmemoskips them all: 0 card renders per keystroke. That is memo where measured:Filters,ColumnandAppstay plain, because they are cheap or always change. With the React Compiler (Topic 16.5) this memoisation would be automatic. - 5
Tests with Testing Library (Phase 17). Test what a user does and sees: type a task and press Enter, click "Move Plan the board right", press Alt + → and check where focus is, read the
statusregion. Queries by role and name (getByRole("region", { name: "To do" }),getByRole("button", { name: "Move ... right" })) are also an accessibility check: if a test can't find the button by its name, a screen reader user can't either. The reducer gets plain function tests. In the preview a tinytest()/expect()helper prints ✓ or ✗; in the project the same tests run in Vitest. - 6
Automated checks catch some issues, not all.
eslint-plugin-jsx-a11yflags missing labels and click handlers on non-interactive elements while you type; axe (Topic 18.5, 17.7) scans the rendered page for contrast and ARIA errors in tests. Neither can tell whether focus lands in the right place or an announcement makes sense: do one keyboard-only pass and one screen reader pass (NVDA, VoiceOver) through the main flows before shipping.
Explain it without notes
Why does focus get lost when a card moves to another column, and how do you fix it?
What is a live region and how should you render one in React?
When would you add memo to TaskCard, and what makes it work?
How do role-based queries in tests double as accessibility checks?
Practice
Make Escape in a card's edit box return focus to that card's ✎ button.
Write a test that the Add button doesn't add an empty task.
Trade-offs
- ↔
Moving focus after every change helps keyboard users but can surprise them if overdone; move it only when the focused element disappears or the user started the action.
- ↔
Testing through the UI is slower than unit tests but survives refactors; keep the fast reducer tests for logic and a few UI tests per flow.
- ↔
memomakes renders cheaper only when props stay stable; the React Compiler removes the manual work but requires code that follows the rules of React.
Done when you can
I can make every action work from the keyboard and add shortcuts on top.
I can keep focus on an element that React re-creates elsewhere.
I can announce changes with a live region rendered from the start.
I add
memoonly after measuring renders, and I know what keeps it effective.I can test a board with role queries, user-event and plain reducer tests.