A customer who works entirely by keyboard wrote in about the invoice editor. The first time she pressed Send with a price missing, focus jumped to the empty price, as it should. She tabbed back to Send to check the total, pressed it again, and nothing happened at all. To her, the button had stopped working.
The editor asked each line to focus a field by passing it a prop, and the line focused it in an effect. A second attempt set the same prop to the same value, so nothing re-ran. Focus is an action, and the editor was describing it as a state.
useImperativeHandle lets a component give its parent a small handle of methods, here a single focus(field), instead of its DOM nodes or a prop to toggle. The parent calls the method when something happens, and the child decides how to carry it out.
A focus request that only worked once
A line has a description and a price, and the first incomplete one stops the invoice:
// The lines on an invoice, and the first problem that stops it being
// sent.
export interface Line {
id: string;
description: string;
price: string;
}
export type LineField = "description" | "price";
export interface Problem {
lineId: string;
field: LineField;
message: string;
}
export function firstProblem(lines: readonly Line[]): Problem | null {
for (const [index, line] of lines.entries()) {
const number = index + 1;
if (!line.description.trim())
return {
lineId: line.id,
field: "description",
message: `Describe line ${number}`,
};
if (!line.price.trim())
return {
lineId: line.id,
field: "price",
message: `Enter a price for line ${number}`,
};
}
return null;
}The first editor passed the problem down, and each line watched for it:
// Avoid: focus asked for through a prop, acted on in an effect
function LineItem({
line,
focus,
}: {
line: Line;
focus: LineField | null;
}) {
const price = useRef<HTMLInputElement>(null);
const description = useRef<HTMLInputElement>(null);
useEffect(() => {
if (focus)
(focus === "price" ? price : description).current?.focus();
}, [focus]);The effect runs when focus changes. The first Send changes it from null to "price" and the price input is focused. The second Send finds the same problem and sets the same field, so the effect’s dependency is unchanged and it doesn’t run. A workaround that clears the prop and sets it again works until someone forgets it, and it spends two renders on a single focus.
Props describe what a component should look like. “Move focus now” is not a look, it is something to do once, at a moment, and React gives you a way to do things at a moment: call a function.
A handle with one verb
Since React 19, a component receives a ref as a prop, like any other. Pass it to an input and the parent gets the DOM node. Pass it to useImperativeHandle and the parent gets whatever object the child returns instead.
A line has two inputs and a parent that only ever needs to say “focus this field”. So the handle has one method that takes the field’s name. The parent can’t restyle the input, read its value or reach the description through the price; the line keeps those to itself, which is what lets it change them later.
Giving each line a focus method
The line exposes its handle, the editor collects one handle per line, and Send calls the right one.
The line’s handle
useImperativeHandle(
ref,
() => ({
focus(field) {
(field === "description"
? description
: price
).current?.focus();
},
}),
[],
);The two inputs keep their own refs, private to the line. The handle’s focus picks one by name. The empty dependency list says the handle never needs rebuilding, because it reads only refs, which stay the same object for the line’s whole life.
The finished line
"use client";
// One line of an invoice. Its parent may ask it to focus a field; how
// the line does that, and which inputs it has, stay inside it.
import { useId, useImperativeHandle, useRef } from "react";
import type { Ref } from "react";
import type { Line, LineField } from "./lines";
export interface LineItemHandle {
focus: (field: LineField) => void;
}
interface LineItemProps {
line: Line;
number: number;
error: { field: LineField; message: string } | null;
onChange: (line: Line) => void;
ref?: Ref<LineItemHandle>;
}
export function LineItem({
line,
number,
error,
onChange,
ref,
}: LineItemProps) {
const id = useId();
const description = useRef<HTMLInputElement>(null);
const price = useRef<HTMLInputElement>(null);
useImperativeHandle(
ref,
() => ({
focus(field) {
(field === "description"
? description
: price
).current?.focus();
},
}),
[],
);
const invalid = (field: LineField) => error?.field === field;
return (
<div role="group" aria-label={`Line ${number}`}>
<label htmlFor={`${id}-description`}>Description</label>
<input
id={`${id}-description`}
ref={description}
value={line.description}
aria-invalid={invalid("description")}
aria-describedby={
invalid("description") ? `${id}-error` : undefined
}
onChange={(event) =>
onChange({ ...line, description: event.target.value })
}
/>
<label htmlFor={`${id}-price`}>Price</label>
<input
id={`${id}-price`}
ref={price}
inputMode="decimal"
value={line.price}
aria-invalid={invalid("price")}
aria-describedby={
invalid("price") ? `${id}-error` : undefined
}
onChange={(event) =>
onChange({ ...line, price: event.target.value })
}
/>
{error ? <p id={`${id}-error`}>{error.message}</p> : null}
</div>
);
}Each input has a visible label inside a group named for its line, so a screen reader names the line along with the field. When the line has the problem, the field is marked invalid and described by the message under it.
One handle per line
A list of lines can’t call useRef once per line, so the editor keeps a Map of handles by id and fills it from a ref callback. In React 19 the callback can return a cleanup function, which removes the line’s handle when it goes away.
"use client";
// The invoice's lines and its Send button. On a problem, the line's
// message is shown and focus moves to the field to fix, on every
// attempt.
import { useRef, useState } from "react";
import { flushSync } from "react-dom";
import { LineItem } from "./line-item";
import type { LineItemHandle } from "./line-item";
import { firstProblem } from "./lines";
import type { Line, Problem } from "./lines";
interface InvoiceLinesProps {
initialLines: readonly Line[];
onSend: (lines: readonly Line[]) => void;
}
export function InvoiceLines({
initialLines,
onSend,
}: InvoiceLinesProps) {
const [lines, setLines] = useState(initialLines);
const [problem, setProblem] = useState<Problem | null>(null);
// Each line's handle by id, kept by its ref callback.
const handles = useRef(new Map<string, LineItemHandle>());
function send() {
const found = firstProblem(lines);
// The message is on screen before focus arrives, so it is read
// with it.
flushSync(() => setProblem(found));
if (found) handles.current.get(found.lineId)?.focus(found.field);
else onSend(lines);
}
return (
<section aria-label="Invoice lines">
{lines.map((line, index) => (
<LineItem
key={line.id}
ref={(handle) => {
if (handle) handles.current.set(line.id, handle);
return () => {
handles.current.delete(line.id);
};
}}
line={line}
number={index + 1}
error={problem?.lineId === line.id ? problem : null}
onChange={(next) =>
setLines(lines.map((l) => (l.id === next.id ? next : l)))
}
/>
))}
<button type="button" onClick={send}>
Send invoice
</button>
</section>
);
}Send finds the first problem, shows its message, and focuses the field, every time. The flushSync call makes React put the message on screen before focus moves, so the field is announced with its description, “Enter a price for line 2”, rather than before it exists.
What the handle promises, and what it hides
A handle is a public API between two components, small enough to read in a line. The work is in keeping it that small.
Verbs, never state
react.dev’s guidance is plain: if you can express something as a prop, don’t use a ref. Whether a dialog is open is a state, so it is a prop; focusing, scrolling into view and selecting text are actions, so they can be methods. A handle with open and close is a sign that state has gone the wrong way, out of React’s sight.
The smallest handle is the DOM node
A component that wraps one input doesn’t need a handle at all. Passing the ref straight to the <input> gives the parent focus() and everything else the browser offers, which is usually fine for a text field. Reach for useImperativeHandle when the component holds several elements, or when the parent should be kept from the node.
Focus that keyboard users can trust
Moving focus is one of the few things a page does on its own to the person using it, so it has to be predictable. The editor moves it only in answer to Send, always to the field that blocks sending, and always with the reason already attached. WCAG’s guidance on error identification asks for the problem to be described in text; the description is what turns a jump of focus into an explanation.
Testing the second attempt
The test presses Send twice, which is exactly what the first version failed:
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, vi } from "vitest";
import { InvoiceLines } from "./invoice-lines";
import type { Line } from "./lines";
const LINES: Line[] = [
{ id: "l1", description: "Brand workshop", price: "1200" },
{ id: "l2", description: "Design system audit", price: "" },
];
const priceOf = (number: number) =>
within(
screen.getByRole("group", { name: `Line ${number}` }),
).getByRole("textbox", { name: "Price" });
test("each try focuses the field, with its message", async () => {
const onSend = vi.fn();
render(<InvoiceLines initialLines={LINES} onSend={onSend} />);
const send = screen.getByRole("button", { name: "Send invoice" });
await userEvent.click(send);
expect(priceOf(2)).toHaveFocus();
expect(priceOf(2)).toHaveAccessibleDescription(
"Enter a price for line 2",
);
await userEvent.click(send);
expect(priceOf(2)).toHaveFocus();
expect(onSend).not.toHaveBeenCalled();
});
test("sends once every line is complete", async () => {
const onSend = vi.fn();
render(<InvoiceLines initialLines={LINES} onSend={onSend} />);
await userEvent.type(priceOf(2), "800");
await userEvent.click(
screen.getByRole("button", { name: "Send invoice" }),
);
expect(onSend).toHaveBeenCalledTimes(1);
expect(priceOf(2)).toHaveAttribute("aria-invalid", "false");
});Both pass. Run the first with the prop-based line and it fails on the second press: focus stays on the button.
Refs are a browser concern
On the server, nothing is mounted and no ref is set, so a handle exists only in the browser after hydration. That suits it: methods like focus only make sense in answer to something a person did. The line and the editor are Client Components, marked with "use client".
React 18, 19 and the Compiler
In React 18 a function component can’t receive ref as a prop; it is wrapped in forwardRef, and the handle is written the same way inside it. React 19 passes ref as a prop, adds cleanup functions to ref callbacks, and plans to deprecate forwardRef in a future version. The React Compiler doesn’t change any of this: refs are read in handlers and the handle’s methods, never during render.
When a prop or a plain ref is enough
Anything that describes how a component should look belongs in props, however tempting a method is. A parent that wants a line highlighted passes highlighted; a parent that wants a panel open passes open. Methods for those states drift out of step with what React renders.
And when the child is one element, forward the ref to it and skip the handle. The hook earns its place when there is a real boundary to keep: several elements, or a component whose insides should be free to change.
How Turtlekit moves focus to a field
With Turtlekit the editor doesn’t hold a single ref. ErrorSummary takes focus on each failed Send and each of its links moves focus to its field; TextField shows the same message under the field:
// The line prices with Turtlekit: ErrorSummary takes focus on each
// failed Send and each of its links moves focus to its field, so the
// form holds no ref.
import { useState } from "react";
import { Button, ErrorSummary, Stack, TextField } from "turtlekit";
import type { ErrorSummaryItem } from "turtlekit";
interface LinePricesProps {
lines: readonly { id: string; description: string }[];
onSend: (prices: FormData) => void;
}
export function LinePrices({ lines, onSend }: LinePricesProps) {
const [errors, setErrors] = useState<ErrorSummaryItem[]>([]);
const errorFor = (id: string) =>
errors.find((e) => e.id === `price-${id}`)?.message;
return (
<Stack
as="form"
gap="stack"
aria-label="Invoice lines"
noValidate
onSubmit={(event) => {
event.preventDefault();
const prices = new FormData(event.currentTarget);
const found = lines.flatMap((line, index) =>
String(prices.get(line.id) ?? "").trim()
? []
: [
{
id: `price-${line.id}`,
message: `Enter a price for line ${index + 1}`,
},
],
);
setErrors(found);
if (!found.length) onSend(prices);
}}
>
<ErrorSummary errors={errors} />
{lines.map((line, index) => (
<TextField
key={line.id}
id={`price-${line.id}`}
name={line.id}
label={`Price, line ${index + 1}`}
hint={line.description}
money="EUR"
error={errorFor(line.id)}
/>
))}
<Button type="submit">Send invoice</Button>
</Stack>
);
}The summary listens for its form’s submits and takes focus after each one that still has problems, the same problems too. So the second Send is never silent, and the form only describes what it shows: the moment of focus belongs to the component that knows when it comes.
Built on this pattern in Turtlekit: Context menu, Dropdown menu and Menubar.
When the handle isn’t there
Most trouble with handles is about when they exist.
ref.current is null when the parent calls it
The child hasn’t mounted yet, or has just been added in the same event. Wrap the state update that adds it in flushSync, or call the method from an effect that runs after it mounts.
The handle calls a stale function
The handle was built with values that have since changed and the dependency list doesn’t name them. List them, or read them through refs as the line does.
The parent receives a DOM node instead of the handle
The ref reached an element before the hook, often through a spread of props onto the input. Take ref out of the props and pass it only to useImperativeHandle.
Methods for moments, props for everything else
The customer who works by keyboard pressed Send twice and got one answer, because the editor had described focus as a state. Focus is an action. A prop says how the screen should be, and saying the same thing twice is, rightly, nothing. A method says do this now, and saying it twice does it twice. Giving the line one method, named for the one thing its parent asks, fixed the second Send and kept every input private to the line that owns it. That is the measure of a good handle: the parent can do what it needs, and nothing it doesn’t.