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.

The latest ref pattern in React

Turtlekit Editorial Team

An accountant spent ten minutes writing payment terms into an invoice’s notes, watched the editor say nothing for the whole time, and copied the text somewhere safe before closing the tab. She was right to. The notes were meant to save every thirty seconds, and they hadn’t saved once.

The first version of the autosave had saved the notes as they were when the invoice opened, over and over. Its fix restarted the thirty seconds on every keystroke, so anyone who typed steadily never reached a save. Both versions were one line apart, and both are what an effect does when a long-lived timer needs a value that keeps changing.

What the timer needed was somewhere to look. That is the latest ref pattern: keep the newest value in a ref, refreshed after every render, so a timer or listener that starts once can read the current value whenever it fires. The timer stays put and the value moves.

A timer that saved the wrong notes

Here is the first version:

// Avoid: the timer saves the notes as they were when the invoice
// opened
export function useAutosaveStale(notes: string, save: SaveNotes) {
  useEffect(() => {
    const timer = setInterval(() => void save(notes), 30_000);
    return () => clearInterval(timer);
  }, []);
}

The empty list says the timer never needs to restart, which is true. But the function given to setInterval was created during the first render, and like every function created in a render it sees that render’s values, as a snapshot. Thirty seconds later, and every thirty seconds after, it saves the notes the invoice opened with. The lint rule for dependencies flags this, and the file had silenced it.

The second version listened to the linter:

// Avoid: every keystroke restarts the 30 seconds
export function useAutosaveRestarting(
  notes: string,
  save: SaveNotes,
) {
  useEffect(() => {
    const timer = setInterval(() => void save(notes), 30_000);
    return () => clearInterval(timer);
  }, [notes, save]);
}

Now every render that changes the notes clears the interval and starts a new one. A pause of thirty seconds saves; a steady typist never pauses that long. The value is fresh and the timer is useless, because restarting it was the only way the effect knew to deliver a new value.

Let the timer read, not capture

The two concerns in the effect move at different speeds. The interval should start when the editor opens and stop when it closes. The notes change with every keystroke. A ref separates them: it is the same object on every render, and changing its current doesn’t re-render anything.

"N""Net""Net 30"latestNotes.currentsetIntervalreads the newest
Each render writes its notes into the ref after it reaches the screen. The interval was started once and never restarted; when it fires, it reads whatever the last committed render left there.

So the timer holds the ref, not the notes, and reads current when it fires. Each render writes its notes into the ref once it has reached the screen. The timer never has to restart to see a new value, because it looks it up at the moment it needs it.

Building the autosave on a latest ref

The pattern is a small hook, and the autosave becomes a second hook built on it. The editor ends up with no timer code of its own.

The latest ref

// A ref that holds the value from the latest render React committed.
import { useEffect, useRef } from "react";
import type { RefObject } from "react";

export function useLatestRef<T>(value: T): RefObject<T> {
  const ref = useRef(value);
  // No dependency list: refreshed after every commit, never during
  // render.
  useEffect(() => {
    ref.current = value;
  });
  return ref;
}

The ref starts with the first value. The effect has no dependency list, so it runs after every commit and copies the value that render committed. It writes in an effect, not in the body, for a reason covered below: a render can be thrown away before it reaches the screen, and its value shouldn’t leak out.

The autosave hook

// Saves the notes every 30 seconds while they have changed. The timer
// starts once; each tick reads the newest notes and the newest save
// function.
import { useEffect, useRef, useState } from "react";
import { useLatestRef } from "./use-latest-ref";

export type SaveNotes = (notes: string) => Promise<void>;
export type Autosave =
  | { status: "idle" }
  | { status: "saved"; at: Date }
  | { status: "failed"; at: Date };

export function useAutosave(
  notes: string,
  save: SaveNotes,
  every = 30_000,
): Autosave {
  const latestNotes = useLatestRef(notes);
  const latestSave = useLatestRef(save);
  const savedNotes = useRef(notes);
  const [autosave, setAutosave] = useState<Autosave>({
    status: "idle",
  });

  useEffect(() => {
    const timer = setInterval(async () => {
      const notes = latestNotes.current;
      if (notes === savedNotes.current) return;
      try {
        await latestSave.current(notes);
        savedNotes.current = notes;
        setAutosave({ status: "saved", at: new Date() });
      } catch {
        setAutosave({ status: "failed", at: new Date() });
      }
    }, every);
    return () => clearInterval(timer);
  }, [every, latestNotes, latestSave]);

  return autosave;
}

/**
 * What the status line says: when the notes were last saved, or that
 * a save failed.
 */
export function autosaveText(autosave: Autosave): string {
  const time = (at: Date) =>
    at.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
  if (autosave.status === "saved")
    return `Notes saved at ${time(autosave.at)}.`;
  if (autosave.status === "failed")
    return "Couldn’t save your notes. Trying again in 30 seconds.";
  return "";
}

The interval starts once, with a dependency list that never changes after the first render: the refs are the same objects throughout. On each tick it reads the newest notes, skips the save when nothing changed since the last one, and reports the outcome. The save function goes through a latest ref as well, so a parent that passes a new function on every render can’t restart the timer. A third ref, savedNotes, remembers what the server already has.

The finished notes field

"use client";
// The notes on an invoice, saved every 30 seconds while someone
// types.
import { useId, useState } from "react";
import { autosaveText, useAutosave } from "./use-autosave";
import type { SaveNotes } from "./use-autosave";

export function InvoiceNotes({
  initialNotes,
  save,
}: {
  initialNotes: string;
  save: SaveNotes;
}) {
  const id = useId();
  const [notes, setNotes] = useState(initialNotes);
  const autosave = useAutosave(notes, save);
  return (
    <section aria-labelledby={`${id}-label`}>
      <label id={`${id}-label`} htmlFor={`${id}-notes`}>
        Notes to the customer
      </label>
      <textarea
        id={`${id}-notes`}
        value={notes}
        rows={4}
        onChange={(event) => setNotes(event.target.value)}
      />
      {/* Empty until the first save; each save announced anew. */}
      <p role="status">
        {autosave.status === "idle" ? null : (
          <span key={autosave.at.getTime()}>
            {autosaveText(autosave)}
          </span>
        )}
      </p>
    </section>
  );
}

Type “Net 30”, keep typing, and thirty seconds after the invoice opened the notes are saved as they are at that moment. The status line says when, “Notes saved at 10:42.”, so it never claims newer edits are safe. If the save fails, it says so and the next tick tries again with whatever the notes are by then.

Why the ref is written where it is

The pattern is a few lines, and each line has a reason. Most of the engineering is in where the ref is written and who reads it.

Never during render

Writing ref.current = value in the component body looks simpler and is the version to avoid. react.dev’s rule is not to write or read a ref during render, except to initialise it. React may render a component and discard the result, for example when a transition is interrupted, and in development it renders twice to catch impure code. A ref written in the body could hold notes from a render nobody saw. Written in an effect, it only ever holds what reached the screen.

One render behind, for a moment

Between a render and its effect the ref still holds the previous value. For a timer that fires every thirty seconds the gap doesn’t matter, and neither does it for listeners that wait for people. It can matter for code that calls through the ref while React is still committing, before the refreshing effect has run. Code like that should receive the value as an argument rather than look it up.

What a ref costs

A ref is invisible to React, which is its point and its price. Nothing re-renders when it changes, so a ref is the wrong place for anything the screen shows; the save status is state for that reason. A ref also hides the value from the dependency linter, so use it only where the effect genuinely shouldn’t restart for that value. Reaching for a ref to quiet a warning moves the bug, it doesn’t fix it.

A status that can be heard

Autosave is quiet by design, which is what failed the accountant: the screen gave no sign either way. The status paragraph is a polite live region that starts empty, and each save puts a new line in it, so every save is announced with its time. A failure says what will happen next rather than leaving people to guess.

Testing with a clock you control

Fake timers make thirty seconds instant, and the first test is the accountant’s session:

import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { InvoiceNotes } from "./invoice-notes";

beforeEach(() => vi.useFakeTimers({ shouldAdvanceTime: true }));
afterEach(() => vi.useRealTimers());

const user = () =>
  userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
const wait = (ms: number) =>
  act(() => vi.advanceTimersByTimeAsync(ms));

test("saves the newest notes; typing doesn't delay it", async () => {
  const save = vi.fn(() => Promise.resolve());
  render(<InvoiceNotes initialNotes="" save={save} />);
  const notes = screen.getByRole("textbox", {
    name: "Notes to the customer",
  });
  await user().type(notes, "Net 30");
  await wait(20_000);
  await user().type(notes, ", by transfer");
  await wait(10_000);
  expect(save).toHaveBeenCalledTimes(1);
  expect(save).toHaveBeenCalledWith("Net 30, by transfer");
  expect(screen.getByRole("status")).toHaveTextContent(
    /^Notes saved at \d\d:\d\d/,
  );
});

test("saves nothing while nothing has changed", async () => {
  const save = vi.fn(() => Promise.resolve());
  render(<InvoiceNotes initialNotes="Net 30" save={save} />);
  await wait(90_000);
  expect(save).not.toHaveBeenCalled();
  expect(screen.getByRole("status")).toHaveTextContent("");
});

test("says so when a save fails, and tries again", async () => {
  const save = vi
    .fn()
    .mockRejectedValueOnce(new Error("offline"))
    .mockResolvedValue(undefined);
  render(<InvoiceNotes initialNotes="" save={save} />);
  await user().type(
    screen.getByRole("textbox", { name: "Notes to the customer" }),
    "Net 30",
  );
  await wait(30_000);
  expect(screen.getByRole("status")).toHaveTextContent(
    "Couldn’t save your notes.",
  );
  await wait(30_000);
  expect(save).toHaveBeenCalledTimes(2);
  expect(screen.getByRole("status")).toHaveTextContent(
    /^Notes saved at \d\d:\d\d/,
  );
});

All three pass. Swap in the second try and the first test fails: the keystrokes at twenty seconds restart the timer, and no save happens at thirty.

Timers belong to the browser

Effects don’t run during server rendering, so on the server the ref holds the first value and no interval starts. That is the right outcome: the server sends the notes as they are, and the browser starts saving once the editor is interactive. Strict Mode in development starts the interval, clears it and starts it again, which the cleanup already handles.

React 18, 19 and the Compiler

The pattern works the same on React 18 and 19. React 19.2 added useEffectEvent, and on 19.2 this autosave needs no refs for the notes or the save: one Effect Event called from the timer reads both. The latest ref stays for code that must run on React 18, and for a stable function handed to other components or hooks, which an Effect Event can’t be. The React Compiler leaves refs alone, and its rules flag reading ref.current during render, which this pattern never does.

When the effect should restart

Most effects should restart when what they read changes. A subscription to the selected invoice must reconnect for a new invoice; reading the invoice id through a ref would keep the old connection open. The latest ref is for values the long-lived thing reads, never for the values that define it.

On React 19.2 and later, prefer useEffectEvent when the latest values are read by code the effect calls, as this timer does: it says the same thing in React’s own words, and the linter checks it.

How Turtlekit uses the latest ref

Turtlekit exports useLatestRef with the contract built above, so the autosave hook can import it from the package instead of carrying its own. The notes field itself becomes a TextArea, which brings the visible label, the character count and a limit people can see:

// The notes with Turtlekit: the same autosave hook, and a TextArea
// that brings the label, the count and the limit.
import { useState } from "react";
import { Text, TextArea } from "turtlekit";
import { autosaveText, useAutosave } from "./use-autosave";
import type { SaveNotes } from "./use-autosave";

export function InvoiceNotes({
  initialNotes,
  save,
}: {
  initialNotes: string;
  save: SaveNotes;
}) {
  const [notes, setNotes] = useState(initialNotes);
  const autosave = useAutosave(notes, save);
  return (
    <>
      <TextArea
        label="Notes to the customer"
        value={notes}
        onValueChange={setNotes}
        maxLength={500}
      />
      <Text as="p" variant="body-compact" tone="muted" role="status">
        {autosave.status === "idle" ? null : (
          <span key={autosave.at.getTime()}>
            {autosaveText(autosave)}
          </span>
        )}
      </Text>
    </>
  );
}

The timer logic stays in the hook, so changing how the field looks never touches when it saves.

Built on this pattern in Turtlekit: Tabs.

When a long-lived callback misbehaves

Three symptoms point at the latest ref, or at its absence.

The timer or listener uses a value from long ago

Its function was created in an early render and has kept that render’s values. Read the value through a latest ref, or list it and accept the restart.

The effect restarts on every render

A function passed from the parent is new each time. Route it through a latest ref, as the save function is here, rather than asking every caller to memoise it.

The screen doesn’t update when the ref changes

That is how refs work. Anything the screen shows belongs in state; the ref is for code that runs later and needs to look something up.

Capture less, look up more

Both broken autosaves made the same assumption: that the timer had to be handed the notes. Once the timer holds a place to look instead of a value, the conflict disappears. It starts once and stops once, the notes change as often as people type, and each tick saves what is on the screen at that moment. That small change in what a callback holds is most of what long-lived code in React needs. The accountant no longer copies her notes before closing the tab.

Effect Events with useEffectEvent in React

When the code that needs the latest values is a function called from an effect.

One effect per concern in React

When an effect restarts because it does several jobs at once.

Custom hooks and hook composition in React

When the ref and its timer belong in a hook with a name.

Detecting the first render in React

When a ref is being used to skip an effect’s first run.