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.

Ref as a prop and ref cleanup in React 19

Turtlekit Editorial Team

A bookkeeper records a payment against an overdue invoice. She chooses Card, the payment provider draws its card number field into the dialog, and she starts typing the number. Then she remembers it is a part payment. The dialog suggests the full balance, €1,200.00, so she presses Change amount, and the amount field appears with the figure already selected, ready for her to type 450.

Each of those moments needs a DOM node. The dialog has to reach an input that lives inside the amount field’s component, and the provider’s script has to be handed a node to draw into and told when that node is gone.

React 19 changed how both are written. A function component now receives ref as an ordinary prop, which puts forwardRef on the way to retirement, and a ref callback can return a cleanup function, so the code that sets something up on a node sits beside the code that takes it down.

Two refs written for React 18

In React 18 a function component never sees a ref passed to it; React keeps it back and warns. The amount field was wrapped in forwardRef so the dialog could reach its input:

// Avoid in new code: a wrapper whose only job is to receive the ref
export const AmountInput = forwardRef<
  HTMLInputElement,
  AmountInputProps
>(function AmountInput({ label, value, onChange }, ref) {
  const id = useId();
  return (
    <>
      <label htmlFor={id}>{label}</label>
      <input
        id={id}
        ref={ref}
        inputMode="decimal"
        value={value}
        onChange={(event) => onChange(event.target.value)}
      />
    </>
  );
});

It works, at a price paid on every component like it. The props type moves into a generic whose order is the reverse of the arguments, the function needs a name of its own to show in DevTools, and the component reads as something other than a function of its props.

The card field was written with a ref callback, the way React 18 allowed:

// Avoid: setup and teardown split by an if, in a callback that is new
// on every render
export function CardDetails({
  provider,
}: {
  provider: CardProvider;
}) {
  const field = useRef<CardField | null>(null);
  return (
    <div
      role="group"
      aria-label="Card details"
      ref={(node) => {
        if (node) {
          field.current = provider.mount(node);
        } else {
          field.current?.unmount();
          field.current = null;
        }
      }}
    />
  );
}

React called this function with the node when the div appeared and with null when it went, so setup and teardown sat in two branches of one if, and the mounted field had to wait in a ref between them. The bug hid in the arrow function itself. It was a new function on every render, and React calls the old callback with null and the new one with the node whenever the callback changes. Every keystroke in the amount re-rendered the dialog, unmounted the provider’s field and drew it again, empty. The card number she had typed was gone.

A ref is a prop, and a ref callback cleans up after itself

Since React 19, a function component receives ref as a prop. The amount field destructures it beside label and value and passes it to the input, and the wrapper has nothing left to do.

A ref callback can now return a function, and React calls that function when the node is detached, instead of calling the callback again with null. Setup and teardown share one closure, the same shape as an effect: what the setup created is right there for the cleanup to undo.

Card chosenAmount typedTransfer chosendraw, one functionmountnothingunmounta new callback each rendermountunmountmountunmount
React calls a ref callback when its node arrives and its cleanup when the node goes, and both again whenever the callback is a different function. Keep one function and a keystroke elsewhere leaves the card field alone.

Cleanup does not make a callback stable, though. React still detaches and attaches again whenever it is handed a different function, so the card field’s callback is kept as one function for as long as the provider is the same.

Rewriting the payment dialog’s two refs

Two small files change, and the dialog that uses them stays almost as it was. Each step removes code rather than adding it.

The amount field takes ref as a prop

// The amount field. Its caller's ref arrives as a prop, like any
// other, and goes to the input.
import { useId } from "react";
import type { Ref } from "react";

interface AmountInputProps {
  label: string;
  value: string;
  onChange: (value: string) => void;
  ref?: Ref<HTMLInputElement>;
}

export function AmountInput({
  label,
  value,
  onChange,
  ref,
}: AmountInputProps) {
  const id = useId();
  return (
    <>
      <label htmlFor={id}>{label}</label>
      <input
        id={id}
        ref={ref}
        inputMode="decimal"
        autoComplete="off"
        value={value}
        onChange={(event) => onChange(event.target.value)}
      />
    </>
  );
}

The ref is typed in the props like everything else, as Ref<HTMLInputElement>, which accepts an object from useRef or a callback. Nothing else in the file mentions refs. The label finds the input through an id from useId, so two amount fields on one page never point at each other’s input.

The card field cleans up in the same closure

// One callback for as long as the provider is the same: React calls
// it when the node arrives and its cleanup when the node goes.
const draw = useCallback(
  (node: HTMLDivElement) => {
    const field = provider.mount(node);
    return () => field.unmount();
  },
  [provider],
);

The field the provider returns lives in the closure, so the ref that held it between two branches is gone. useCallback keeps draw the same function across renders, and React leaves an unchanged ref callback alone: typing in the amount re-renders the card field without touching the node.

The dialog

"use client";
// Recording a payment against an invoice. Change amount selects the
// figure, ready to type; choosing Card draws the card field.
import { useRef, useState } from "react";
import { flushSync } from "react-dom";
import { AmountInput } from "./amount-input";
import { CardDetails } from "./card-details";
import { formatAmount } from "./payment";
import type { CardProvider, Method } from "./payment";

interface RecordPaymentProps {
  balance: string;
  provider: CardProvider;
  onRecord: (amount: string, method: Method) => void;
}

export function RecordPayment({
  balance,
  provider,
  onRecord,
}: RecordPaymentProps) {
  const [amount, setAmount] = useState(balance);
  const [editing, setEditing] = useState(false);
  const [method, setMethod] = useState<Method>("transfer");
  const amountInput = useRef<HTMLInputElement>(null);

  function change() {
    // The input exists once this update is on screen.
    flushSync(() => setEditing(true));
    amountInput.current?.focus();
    amountInput.current?.select();
  }

  return (
    <form
      aria-label="Record payment"
      onSubmit={(event) => {
        event.preventDefault();
        onRecord(amount, method);
      }}
    >
      {editing ? (
        <AmountInput
          ref={amountInput}
          label="Amount"
          value={amount}
          onChange={setAmount}
        />
      ) : (
        <p>
          {formatAmount(amount)}, the full balance{" "}
          <button type="button" onClick={change}>
            Change amount
          </button>
        </p>
      )}
      <fieldset>
        <legend>Paid by</legend>
        {(["transfer", "card"] as const).map((option) => (
          <label key={option}>
            <input
              type="radio"
              name="method"
              checked={method === option}
              onChange={() => setMethod(option)}
            />
            {option === "card" ? "Card" : "Bank transfer"}
          </label>
        ))}
      </fieldset>
      {method === "card" ? <CardDetails provider={provider} /> : null}
      <button type="submit">Record payment</button>
    </form>
  );
}

Change amount replaces the summary with the input, and flushSync puts that update on screen before the next line runs, so amountInput.current is the new input. It takes focus, and select() selects 1200.00, so typing replaces it. The button that had focus is gone, and focus has moved somewhere useful instead of falling back to the page. Choose Card and the provider draws its field once; choose Bank transfer and its cleanup runs.

What changes, and what React still asks of you

Together the two changes make refs read like the rest of a component, which is where the remaining rules start to matter.

forwardRef’s retirement path

forwardRef still works in React 19, and react.dev says it will be deprecated in a future release; there is no date yet. So the move is gradual. New components take ref as a prop. Existing ones change when someone is in the file, since the edit is mechanical: move the ref into the props, delete the wrapper and its generic. A library that supports React 18 keeps forwardRef until it drops 18, because 18 never hands a ref to a function component. Its callers write ref={amount} the same way on either version.

Two refs on one element

A component that needs the node itself and also passes it to its caller has to give one element two refs. In React 19 the merge has a new duty: if the caller’s ref is a callback that returns a cleanup, the merge must call that cleanup rather than call the callback with null.

// One ref callback that gives a node to several refs, and takes it
// back from each: a callback ref's own cleanup is kept and called.
import type { Ref, RefCallback } from "react";

export function mergeRefs<T>(
  ...refs: (Ref<T> | undefined)[]
): RefCallback<T> {
  return (node) => {
    const cleanups = refs.map((ref) => {
      if (typeof ref === "function") {
        const cleanup = ref(node);
        return typeof cleanup === "function"
          ? cleanup
          : () => ref(null);
      }
      if (ref) ref.current = node;
      return () => {
        if (ref) ref.current = null;
      };
    });
    return () => cleanups.forEach((cleanup) => cleanup());
  };
}

Create the merged callback once per component, with useMemo or useCallback. Built fresh in render, it detaches and attaches on every render, which is the card field’s bug again.

Focus that follows the person

The only focus this dialog moves is in answer to a press of Change amount, and it moves from a button that disappears to the field that replaced it. Moving focus after a choice in a radio group, say, would surprise people: WCAG counts a move of focus as a change of context, which changing a setting shouldn’t cause on its own. The card field is a named group, so a screen reader announces “Card details” as people enter the provider’s inputs.

Testing under Strict Mode

Since React 19, Strict Mode runs one extra setup and cleanup for each ref callback when it first mounts, in development only, to show that the cleanup undoes the setup. The test renders the dialog in Strict Mode with a stand-in provider that counts the fields on the page:

import { StrictMode, createRef } from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, vi } from "vitest";
import { mergeRefs } from "./merge-refs";
import type { CardProvider } from "./payment";
import { RecordPayment } from "./record-payment";

// A stand-in for the provider's script: it counts the fields it has
// drawn and the ones still on the page.
function fakeProvider() {
  const drawn = new Set<HTMLElement>();
  let mounts = 0;
  const provider: CardProvider = {
    mount(node) {
      mounts += 1;
      drawn.add(node);
      return { unmount: () => drawn.delete(node) };
    },
  };
  return { provider, onPage: () => drawn.size, mounts: () => mounts };
}

function renderPayment(provider: CardProvider) {
  render(
    <StrictMode>
      <RecordPayment
        balance="1200.00"
        provider={provider}
        onRecord={vi.fn()}
      />
    </StrictMode>,
  );
}

test("Change amount selects the figure, ready to type", async () => {
  renderPayment(fakeProvider().provider);
  await userEvent.click(
    screen.getByRole("button", { name: "Change amount" }),
  );
  const amount = screen.getByRole("textbox", { name: "Amount" });
  expect(amount).toHaveFocus();
  expect(amount).toHaveProperty("selectionStart", 0);
  expect(amount).toHaveProperty("selectionEnd", "1200.00".length);
  await userEvent.keyboard("450");
  expect(amount).toHaveValue("450");
});

test("keeps one card field while typing, then drops it", async () => {
  const card = fakeProvider();
  renderPayment(card.provider);
  await userEvent.click(
    screen.getByRole("button", { name: "Change amount" }),
  );
  await userEvent.click(screen.getByRole("radio", { name: "Card" }));
  expect(
    screen.getByRole("group", { name: "Card details" }),
  ).toBeVisible();
  expect(card.onPage()).toBe(1);
  const drawn = card.mounts();
  await userEvent.type(
    screen.getByRole("textbox", { name: "Amount" }),
    "5",
  );
  expect(card.mounts()).toBe(drawn);
  await userEvent.click(
    screen.getByRole("radio", { name: "Bank transfer" }),
  );
  expect(card.onPage()).toBe(0);
});

test("mergeRefs gives each ref the node and takes it back", () => {
  const object = createRef<HTMLDivElement>();
  const cleanup = vi.fn();
  const node = document.createElement("div");
  const detach = mergeRefs(object, () => cleanup)(node);
  expect(object.current).toBe(node);
  if (typeof detach === "function") detach();
  expect(object.current).toBeNull();
  expect(cleanup).toHaveBeenCalledTimes(1);
});

All three pass. Give CardDetails the inline callback from the first version and the second test fails on the keystroke: the field is drawn again.

Refs exist only in the browser

On the server nothing is attached, so no ref callback runs and ref.current stays null. The provider draws its field after hydration, and only if Card is chosen. Both components hold state or touch the DOM, so they are Client Components, marked with "use client". A Server Component can’t hand the dialog its provider, an object with methods, or a ref; a Client Component that creates the provider renders the dialog, and the Server Component renders that.

The Compiler and TypeScript

The React Compiler memoises functions created during render, so with it an inline ref callback keeps its identity while its inputs do; without it, useCallback does that job. React 19’s types read a function returned from a ref callback as its cleanup, so an arrow function that returns the node, ref={(node) => (input = node)}, no longer type-checks. Give it a body in braces.

When a ref callback is the wrong tool

A ref callback is for work tied to one node’s life: drawing into it, observing it, measuring it. Work tied to the component’s life, such as a subscription to a socket or a timer, belongs in an effect, which also sees the props it depends on.

And when the parent only needs to call a method or two, such as focus, pass it a small handle with useImperativeHandle rather than the node. Anything that describes how the child should look is still a prop, not a ref.

How Turtlekit’s fields take a ref

TextField takes ref and hands it to its input, on React 19 and on React 18 alike. The dialog’s amount passes it a ref object from useRef, and needs no wrapper and no id:

// The payment amount with Turtlekit: TextField takes ref as a prop
// and hands it to its input, on React 19 and React 18 alike.
import { useRef, useState } from "react";
import { flushSync } from "react-dom";
import { Button, Stack, Text, TextField } from "turtlekit";

const euros = new Intl.NumberFormat("en-IE", {
  style: "currency",
  currency: "EUR",
});

export function PaymentAmount({ balance }: { balance: number }) {
  const [editing, setEditing] = useState(false);
  const amount = useRef<HTMLInputElement>(null);

  function change() {
    flushSync(() => setEditing(true));
    amount.current?.focus();
    amount.current?.select();
  }

  if (editing)
    return (
      <TextField
        ref={amount}
        name="amount"
        label="Amount"
        money="EUR"
        defaultValue={balance.toFixed(2)}
      />
    );
  return (
    <Stack gap="tight" align="start">
      <Text as="p">{euros.format(balance)}, the full balance</Text>
      <Button variant="soft" onClick={change}>
        Change amount
      </Button>
    </Stack>
  );
}

The field brings its label, its euro sign and its wiring, and the ref still reaches the input, so Change amount selects the figure as before. The caller writes the same line on either version of React, which is the whole of forwardRef’s retirement as far as a product is concerned.

Built on this pattern in Turtlekit: Text field and Toolbar.

When the ref doesn’t arrive

ref.current is null in the parent

The child takes ref as a prop but never passes it to an element, or passes it only on one branch of its render. In React 18 the cause is usually a missing forwardRef.

The node is set up again on every keystroke

The ref callback is a new function each render. Keep it with useCallback, move it out of the component if it reads nothing from render, or let the Compiler memoise it.

Pitfall

A callback that returns a cleanup is never called with null. Code that waits for null to tear down will wait forever once someone adds a return, so move the teardown into the cleanup in the same edit.

Refs that read like the rest of the component

For years a ref was the one prop that wasn’t a prop, and a ref callback was a function that had to ask whether it was arriving or leaving. React 19 settles both. The amount field takes its ref beside its label, and the card field keeps one callback that says, in one closure, what it builds and how it comes down, so the bookkeeper’s card number survives every figure she types above it. Code that touches the DOM can now be as easy to read, and as hard to leave half undone, as the code that renders it.

Exposing methods with useImperativeHandle in React

When the parent should get a few methods rather than the node itself.

The latest ref pattern in React

When a ref holds the newest value for a timer or listener, not a node.

Accessible ids with useId in React

When the node needs an id that matches between the server and the browser.

One effect per concern in React

When the setup belongs to the component’s life rather than to one node.