Skip to content
Get the beta

Search the docs

Start with the beta
Installation
Build your first page
Thinking in Turtlekit
Apps, websites and embeds
Components or native elements
Colour
Typography
Layout
Spacing, icons and shape
Components
Root
Layout
Typography
Code block
Button
Icon button
Button group
Call to action
Toolbar
Field
Text field
Text area
Select
Combobox
Date input
Save bar
Newsletter signup
File upload
Calendar
One-time code
Checkbox
Radio group
Switch
Segmented control
Toggle
Slider
Badge
Card
Data list
Item
Icon tile
Num
Time
Metric
Accordion
FAQ
Avatar
Media
Scroll area
Resizable
Carousel
Image slider
Logo cloud
Testimonials
Table
Data table
Chart
Site header
Site footer
Tabs
Page header
Breadcrumb
Pagination
Sidebar
Navigation menu
Callout
Error summary
Toast
Empty state
Skeleton
Progress
Spinner
Tooltip
Dialog
Popover
Hover card
Dropdown menu
Context menu
Menubar
Command
Elements area
Visually hidden
Error boundary
Design rules
Staying within the kit
Layout
Icons
Images and media
Typography
Readability
Colour
Composition
Accessibility
Elements
Motion
Interaction
AI pitfalls
Pricing
React design patterns
Single responsibility in React components
Presentational and container components in React
Children, function children and render props in React
The variant pattern in React
Compound components in React
Higher-order components in React
State colocation in React
Lifting state up in React
Deriving state during render
One status instead of many booleans
Adjusting state when a prop changes
Reading context with use() in React 19
Context selectors in React
Custom hooks and hook composition in React
One effect per concern in React
The latest ref pattern in React
Effect Events with useEffectEvent in React
Detecting the first render in React
Exposing methods with useImperativeHandle in React
Debouncing input in React
useDeferredValue in React
Loading components on demand in React
Virtualising long lists in React
Guard clauses and early returns in React
Skeleton placeholders in React
Rendering partial data in React
Empty states in React
Error boundaries in React
Reading async data with use() and Suspense in React
useTransition and Actions in React
Optimistic UI updates with useOptimistic
useActionState and form Actions in React
useSyncExternalStore in React
Ref as a prop and ref cleanup in React 19
Accessible ids with useId in React
Portals in React
Roving focus and keyboard navigation in React
Controlled and uncontrolled components in React
The state reducer and prop getters in React
Headless components and the provider pattern in React
The polymorphic as prop in React

Appearance

These change every page and preview on this site.

Exposing methods with useImperativeHandle in React

Turtlekit Editorial Team

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.

refInvoiceLinesfocus(field)descriptionpriceLineItem
The parent holds the handle, not the inputs. LineItem decides what focus(field) does, and can rename, add or rearrange its inputs without the parent changing.

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.

Compound components in React

When parts share state through their parent rather than through methods.

Lifting state up in React

When the parent should own a value and pass it down as a prop instead.

Effect Events with useEffectEvent in React

When an effect, not a parent, needs to call the latest handler.

Custom hooks and hook composition in React

When the shared part is behaviour that many components call.