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.

Detecting the first render in React

Turtlekit Editorial Team

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.

ProductionmountskipDevelopment, Strict ModemountskipcleanuponTrack
Strict Mode runs each effect, cleans it up and runs it again when a component first appears. The ref survives the cleanup, so the second run doesn’t see a first render, and the change is reported on load.

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:

What a first-render check usually means
The questionThe 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.

One effect per concern in React

When an effect does several jobs and one of them shouldn’t run when it does.

Adjusting state when a prop changes

When the real question is what to do when a prop changes.

The latest ref pattern in React

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

Deriving state during render

When the value the flag protects can be computed during render.