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