Command Palette

Search for a command to run...

Hectal
PHASE 27Advanced ~41 min· topic 5 of 6

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 with null, 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.

src/components/TaskCard.jsxwhole filejsx
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>
);
Remember the card, focus it when it landsdiagram
Rendering diagram…

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.

src/components/Board.test.jsxwhole filejsx
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.

Terminalwhole filebash
$ 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. 1

    Break the focus on purpose

    In the test example, delete the ref={...} line from TaskCard. Predict which test fails and its message, then run. Put it back.

  2. 2

    Measure memo's limit

    In the render-count example, change TASKS.filter(...) to TASKS.map((t) => ({ ...t })).filter(...). Predict the memo counts. (Every card renders again: new objects every render defeat memo.)

  3. 3

    Test the alert

    Write a test for milestone 5's rollback idea in the test example: give Board an onMove that fails, or add a failNext flag, and expect getByRole("alert") to contain Could not move.

Code & diagrams

Milestone 5: keyboard moves, focus that follows, announcements, memo New tab

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

Press Run to see the page.

Console

Nothing logged yet.
Measure first: render counts with and without memo New tab

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

Press Run to see the page.

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 }
The board's tests, running live with Testing Library New tab

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

Press Run to see the page.

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 it

Break 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>.

terminal
$ Console
── what you'll see ──
✓ 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 it: expected true, got false

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.

terminal
$ Screen reader (NVDA, VoiceOver)
── what you'll see ──
The first move is often not announced at all. The page looks identical.

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; the focusAfterMove === task.id check 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 order field, a reordered action, focus restore and an announcement such as Write 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 preview with profiling enabled) before keeping it (Topic 16.6).

  • ▸

    Run axe in the test suite (vitest-axe or @axe-core/playwright in 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. 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 + ← / → in onKeyDown; the key event bubbles from whichever button inside the card has focus. aria-describedby points the card at a visible hint, so a screen reader reads the shortcut too.

  2. 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 calls node.focus(). The <li> has tabIndex={-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. 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 use role="alert" (assertive: announced at once). The region is visually hidden with an sr-only class, not display: none, which would hide it from screen readers too.

  4. 4

    Performance: measure, then memo (Topic 16.1, 16.3 and 16.6). Typing in Search re-renders Board, and by default every TaskCard with 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-level moveTask (no new function props), wrapping TaskCard in memo skips them all: 0 card renders per keystroke. That is memo where measured: Filters, Column and App stay plain, because they are cheap or always change. With the React Compiler (Topic 16.5) this memoisation would be automatic.

  5. 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 status region. 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 tiny test()/expect() helper prints ✓ or ✗; in the project the same tests run in Vitest.

  6. 6

    Automated checks catch some issues, not all. eslint-plugin-jsx-a11y flags 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

01

Why does focus get lost when a card moves to another column, and how do you fix it?

02

What is a live region and how should you render one in React?

03

When would you add memo to TaskCard, and what makes it work?

04

How do role-based queries in tests double as accessibility checks?

Practice

01

Make Escape in a card's edit box return focus to that card's ✎ button.

02

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.

  • ↔

    memo makes 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 memo only after measuring renders, and I know what keeps it effective.

  • I can test a board with role queries, user-event and plain reducer tests.