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