The data team brought one chart to the roadmap meeting: almost everyone who opened the invoice list changed its status filter. That would have made the filter the most used control in the product. It wasn’t. The “filter changed” event was being sent the moment the list appeared, before anyone touched it.
The fix that shipped was a familiar one: a ref named isFirstRender, so the effect could skip its first run. The production numbers fell to something believable. Then a developer running the app locally saw the event fire on load again, every time, and the two environments disagreed about what a first render was.
Detecting the first render means telling a component’s first render apart from the ones after it. It is a question React seldom needs you to ask. Behind it there is nearly always a sharper one, and each sharper question has a tool that answers it without a flag.
An event that fired on page load
The filter offers three statuses and reports a change through a tracking function:
// The invoice list's filter: the statuses it offers, and what it
// reports.
export type Status = "all" | "overdue" | "paid";
export interface Invoice {
id: string;
number: string;
status: Exclude<Status, "all">;
}
export const STATUSES: { value: Status; label: string }[] = [
{ value: "all", label: "All" },
{ value: "overdue", label: "Overdue" },
{ value: "paid", label: "Paid" },
];
export type Track = (
event: "invoice_filter_changed",
status: Status,
) => void;
export const shownBy = (
invoices: readonly Invoice[],
status: Status,
) =>
status === "all"
? invoices
: invoices.filter((invoice) => invoice.status === status);The first version reported the change from an effect, and the fix taught it to skip a run:
// Avoid: an effect that reports a person's choice, skipping its
// first run
const isFirstRender = useRef(true);
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
onTrack("invoice_filter_changed", status);
}, [status, onTrack]);Without the ref, the effect runs after the first render like every effect, and reports a change nobody made. With it, production is right and development isn’t. In development, Strict Mode runs each effect, cleans it up and runs it again when the component first appears, to prove the cleanup is correct. The ref keeps its value across that check, so the second run finds it already false.
Strict Mode wasn’t the problem; it showed the problem early. The same component would misreport in production too the day it was hidden and shown again inside an <Activity> boundary, which keeps state while effects stop and start.
Ask what the first render stands for
A render is React working out what the screen should show. It can happen once or several times for one change, and some renders never reach the screen. Counting renders is therefore a weak signal, and the effect above was using it as a stand-in for something it couldn’t see: whether a person had chosen.
react.dev gives the test that resolves it: ask why the code needs to run. If it runs because the component appeared, it is an effect. If it runs because someone did something, it belongs in the handler for that action, and there is no first render to skip.
Replacing the flag with the right tool
Most first-render checks are one of a few questions in disguise. Each has a direct answer that behaves the same in development and production:
| The question | The answer |
|---|---|
| What should this start as? | The useState argument or initializer, read on the first render only |
| Has someone changed it? | The event handler for the change |
| What happens when it appears? | An effect, with a cleanup that undoes it |
| Did a prop change since last time? | The previous value kept in state and compared during render, or a key |
| Has the app started yet? | A flag at the top of a module, outside every component |
The filter needs the first two: a starting status from the person’s saved view, and a report when they choose another.
The starting value, read once
useState(initialStatus) already does what the flag was trying to: React reads the argument on the first render and ignores it after that. For a starting value that is expensive to build, pass a function, useState(() => build(view)), and React calls it once. A ref can do the same through the one form react.dev allows during render, lazy initialisation: if (ref.current === null) ref.current = build(view).
The report, where the choice happens
The radio’s change handler knows something no render can: a person just chose. So it sets the status and reports it, in the same few lines. A second piece of state, chosen, records that a choice was made, which the status line needs.
The finished filter
"use client";
// The invoice list's status filter. A person's choice is reported
// where it happens, in the handler, so no render needs to be told
// apart.
import { useId, useState } from "react";
import { STATUSES, shownBy } from "./invoices";
import type { Invoice, Status, Track } from "./invoices";
interface InvoiceFiltersProps {
invoices: readonly Invoice[];
initialStatus: Status;
onTrack: Track;
}
export function InvoiceFilters({
invoices,
initialStatus,
onTrack,
}: InvoiceFiltersProps) {
const id = useId();
// Read on the first render only; later renders ignore the argument.
const [status, setStatus] = useState(initialStatus);
// Whether someone has chosen yet: state, set by the event itself.
const [chosen, setChosen] = useState(false);
const shown = shownBy(invoices, status);
function choose(next: Status) {
setStatus(next);
setChosen(true);
onTrack("invoice_filter_changed", next);
}
return (
<section aria-labelledby={`${id}-title`}>
<h2 id={`${id}-title`}>Invoices</h2>
<fieldset>
<legend>Status</legend>
{STATUSES.map((option) => (
<label key={option.value}>
<input
type="radio"
name={`${id}-status`}
checked={status === option.value}
onChange={() => choose(option.value)}
/>
{option.label}
</label>
))}
</fieldset>
{/* Empty until a choice, so the count is announced then. */}
<p role="status">
{chosen
? `${shown.length} of ${invoices.length} invoices shown.`
: ""}
</p>
<ul>
{shown.map((invoice) => (
<li key={invoice.id}>{invoice.number}</li>
))}
</ul>
</section>
);
}Opening the list reports nothing and announces nothing, in development and in production. Choosing “Overdue” reports one change and the status line reads “2 of 3 invoices shown.” Opening the list from a saved view of paid invoices starts on “Paid” and, again, reports nothing.
Why renders make a poor clock
The flag looked harmless because it usually works. The reasons it fails are the same reasons React can render the way it does.
A ref read during render
Some versions check the ref in the component body instead, flipping it the first time the body runs. react.dev asks you not to read or write a ref during render, apart from lazy initialisation, and the React Hooks linter reports it. The body may run more than once for one render that reaches the screen, and a write made in a render React throws away isn’t undone. Anything the screen shows that depends on the flag is then wrong in ways that are hard to reproduce.
Effects are built to run again
An effect describes synchronisation, so React is free to stop and start it: Strict Mode does it in development, and hidden content inside <Activity> does it in production. Code that counts an effect’s runs is counting something React never promised to keep stable. react.dev’s advice for the double run in development is to make the effect resilient to it, not to guard it with a ref.
Announcing the choice, not the page
The status line renders empty and fills only after a choice, so screen readers hear the new count when it is news, and nothing extra while the page loads. The filter is a native radio group inside a fieldset, so its legend is read with each option and the arrow keys move between them.
Testing under Strict Mode
The test renders inside <StrictMode>, which is where the flag failed, and states what the data team needed:
import { StrictMode } from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, vi } from "vitest";
import { InvoiceFilters } from "./invoice-filters";
import type { Invoice } from "./invoices";
const INVOICES: Invoice[] = [
{ id: "1", number: "INV-0046", status: "overdue" },
{ id: "2", number: "INV-0047", status: "paid" },
{ id: "3", number: "INV-0048", status: "overdue" },
];
test("opening the list reports nothing, even in Strict Mode", () => {
const onTrack = vi.fn();
render(
<StrictMode>
<InvoiceFilters
invoices={INVOICES}
initialStatus="all"
onTrack={onTrack}
/>
</StrictMode>,
);
expect(onTrack).not.toHaveBeenCalled();
expect(screen.getByRole("status")).toHaveTextContent("");
expect(screen.getAllByRole("listitem")).toHaveLength(3);
});
test("a person's choice is reported once and announced", async () => {
const onTrack = vi.fn();
render(
<StrictMode>
<InvoiceFilters
invoices={INVOICES}
initialStatus="all"
onTrack={onTrack}
/>
</StrictMode>,
);
await userEvent.click(
screen.getByRole("radio", { name: "Overdue" }),
);
expect(onTrack).toHaveBeenCalledTimes(1);
expect(onTrack).toHaveBeenCalledWith(
"invoice_filter_changed",
"overdue",
);
expect(screen.getByRole("status")).toHaveTextContent(
"2 of 3 invoices shown.",
);
});
test("the saved view starts chosen and reports nothing", () => {
const onTrack = vi.fn();
render(
<InvoiceFilters
invoices={INVOICES}
initialStatus="paid"
onTrack={onTrack}
/>,
);
expect(screen.getByRole("radio", { name: "Paid" })).toBeChecked();
expect(screen.getAllByRole("listitem")).toHaveLength(1);
expect(onTrack).not.toHaveBeenCalled();
});All three pass. Swap in the version with the flag and the first fails: the second run of the effect reports a change on load.
The server render is a first render too
On the server, React renders once and runs no effects, then the browser renders again to hydrate. Code that does something different “on the first render” has to give the same output in both, or hydration fails. Reading initialStatus from a prop works, because the server and the browser receive the same value. Reading a saved view from localStorage in the initializer wouldn’t, because the server has no such storage; read it on the server, or after hydration in an effect.
React 18, 19 and the Compiler
Strict Mode has run effects twice on mount in development since React 18, and React 19 keeps it. <Activity> arrived in React 19.2, which makes effects that survive a stop and a start a production concern too. The React Compiler assumes the rules of React: a component that reads a ref during render is one it leaves unoptimised, and the linter that shares its rules reports the line.
When once really means once
Some code must run once per page load, not once per component: reading a token, setting up an SDK. Put it at the top of the app’s entry module, or behind a module-level variable checked in the root component’s effect, as react.dev’s application initialisation example does. That flag lives outside React, so no render or remount can reset it.
And when the real question is what to do after a prop changes, compare with the previous value as Adjusting state when a prop changes shows, rather than counting renders.
How Turtlekit reports a choice
Every Turtlekit control reports changes the way the finished filter does: from the person’s action. SegmentedControl calls onValueChange when someone picks an option and never when it first renders, so the tracking call can sit in that handler:
// The status filter with Turtlekit: SegmentedControl calls
// onValueChange when a person chooses, never when it first renders.
import { useState } from "react";
import { SegmentedControl } from "turtlekit";
import { STATUSES } from "./invoices";
import type { Status, Track } from "./invoices";
const isStatus = (value: string): value is Status =>
STATUSES.some((s) => s.value === value);
export function StatusFilter({
initialStatus,
onTrack,
}: {
initialStatus: Status;
onTrack: Track;
}) {
const [status, setStatus] = useState(initialStatus);
return (
<SegmentedControl
label="Status"
options={STATUSES}
value={status}
onValueChange={(next) => {
if (!isStatus(next)) return;
setStatus(next);
onTrack("invoice_filter_changed", next);
}}
/>
);
}Rendering the filter, in Strict Mode or from a saved view, reports nothing. Controls that keep their own state take a defaultValue that is read once, on the first render, like the argument to useState.
When something runs at the wrong time
Three symptoms send people looking for a first-render flag.
An event is reported when the page loads
The report is in an effect that depends on the value. Move it to the handler that changes the value.
Something runs twice in development only
Strict Mode is checking an effect’s cleanup. Give the effect a cleanup that undoes it; if the code can’t be undone, such as a payment, it belongs in an event handler.
The component ignores a new initial value
useState reads its argument once. To start again from a new value, render the component with a key that changes with it.
The first render is rarely the question
The flag was an honest attempt to say “only when someone changes it” in a language that had no word for someone. Event handlers are that word. Once the report moved into the handler, the filter stopped needing to know which render it was in, and the question that split production from development disappeared with the ref. The chart the data team brings next time will count choices people made.