- URL: https://isaacfei.com/posts/editor-autosave-tanstack-start
- Date: 2026-02-23
- Tags: TanStack Start, React, TanStack Query, Auto-save, Editor
- Description: Build a document editor frontend with auto-save using TanStack Start, focusing on editor features and state management in the useDocumentEditor hook.
This post walks through building a document editor frontend with auto-save using TanStack Start. The central piece is useDocumentEditor — a custom hook that owns the entire editing lifecycle: local state, server sync, checksum-based dirty detection, debounced auto-save, and a unified status enum. By the end, you'll understand every design decision in this hook and how they fit together.
You can try the live demo at playground.isaacfei.com/editor — create a document, type some content, and watch the auto-save in action.
What We're Building
A simple multi-document text editor with the following features:
- Document list — browse all documents, create new, navigate to editor
- Title editing — inline input, save on blur / Enter, no dirty tracking
- Content editing — textarea with manual save button, save on blur, and debounced auto-save
- Non-blocking saves — the user can keep typing while a save is in flight
- Status feedback — loading spinner, "Unsaved" / "Saving..." / "Saved" text
-
Safety — unsaved-changes prompt on in-app navigation,
beforeunloadon tab close - Delete — confirmation dialog, redirect after delete
The editor has two pages:
-
Document list (
/editor) — shows all documents as clickable cards with a "New Document" button. -
Document editor (
/editor/documents/$id) — title input, content textarea, status text, save and delete buttons.
The key behavior: while editing content, the editor automatically saves when the user stops typing — without any explicit action. Saves happen in the background without blocking user input, and the user gets visual feedback (status text) and a safety net (unsaved-changes prompt) at all times.
How Auto-Save Works (High-Level)
Before diving into code, here's the auto-save strategy at a glance:
Every keystroke resets a 2-second debounce timer. Once the user stops typing for 2 seconds, the timer fires and triggers a save — but only if there are unsaved changes. If a save is already in flight, new changes are queued and automatically flushed when the current save completes.
API Spec
The frontend expects these endpoints. We won't cover server-side implementation — just the contract:
| Method | Endpoint | Request Body | Response |
|---|---|---|---|
| GET | /api/editor/documents |
— | Document[] |
| POST | /api/editor/documents |
— | { id } |
| GET | /api/editor/documents/:id |
— | Document |
| PUT | /api/editor/documents/:id |
{ title?, content? } |
{ id } |
| DELETE | /api/editor/documents/:id |
— | 204 No Content |
Where the Document type is:
type Document = {
id: string;
title: string | null;
content: string | null;
checksum: string | null;
};
The checksum is an MD5 hash of the content, computed server-side and stored alongside the document. The client uses this as the reference for dirty detection — more on this later.
The PUT endpoint accepts partial updates — you can send just title, just content, or both. This matters because the hook can merge title and content changes into a single request.
Route Structure
Two routes, file-based:
The editor page extracts id from the URL and passes it as a prop:
```tsx title="routes/_main/editor/documents/$id.tsx"
import { createFileRoute } from "@tanstack/react-router";
import { DocumentEditor } from "@/features/editor/components/document-editor";
export const Route = createFileRoute("/_main/editor/documents/$id")({
component: DocumentEditorPage,
});
function DocumentEditorPage() {
const { id } = Route.useParams();
return ;
}
No loader, no server-side data fetching at the route level. Data loading happens inside the component via [TanStack Query](https://tanstack.com/query) hooks. This keeps the route thin and puts all editor logic in the feature module.
## Deep Dive: `useDocumentEditor`
This is the core of the editor. Let's walk through every section.
### The Return Value (Public API)
Before diving into internals, here's what the hook exposes to the component:
```ts
return {
title, // current title string
setTitle, // update title locally (no save)
content, // current content string
updateContent, // update content + schedule debounced auto-save
status, // "loading" | "idle" | "dirty" | "saving"
isDirty, // whether content has unsaved changes
save, // manual save (content, cancels pending debounce)
saveTitle, // queue title save via flushSave
};
The component doesn't know about checksums, debounce timers, refs, or server documents. It gets a clean interface: read values, call actions, check status. isDirty is exposed separately from status because the navigation blocker needs to know about unsaved changes even while a save is in flight (when status is "saving").
External Hooks: Server State
The hook delegates server communication to two TanStack Query wrappers — one useQuery for fetching and one useMutation for saving:
const { mutate: saveDocument, isPending: isSaving } = useSaveDocument();
const { data: serverDocument, isLoading } = useGetDocument(documentId);
useGetDocument is a standard useQuery:
// features/editor/services/use-get-document.ts
export function useGetDocument(id: string | undefined) {
return useQuery({
queryKey: ["document", id ?? ""],
queryFn: () => getDocument(id!),
enabled: !!id,
});
}
useSaveDocument is a useMutation with an important onSuccess handler. After a successful save, it updates the query cache directly via setQueryData — including recomputing the checksum — so the UI immediately reflects the saved state without a refetch:
// features/editor/services/use-save-document.ts
export function useSaveDocument() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: saveDocument,
onSuccess: (_, variables) => {
queryClient.setQueryData<Document>(["document", variables.id], (old) => {
if (!old) return old;
const updates: Partial<Document> = {};
if (variables.title !== undefined) updates.title = variables.title;
if (variables.content !== undefined) {
updates.content = variables.content || null;
updates.checksum =
updates.content != null
? computeChecksum(updates.content)
: null;
}
return { ...old, ...updates };
});
queryClient.invalidateQueries({ queryKey: ["documents"] });
},
});
}
This cache update is critical for the dirty detection feedback loop. When the mutation succeeds, serverDocument.checksum in the query cache gets updated to match the saved content. This means the reactive isDirty flips back to false immediately, without a network round-trip. Here's the cycle:
Local State: The Editable Copy
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
These are the editable copies. The user types into these, not into serverDocument. The server document is the source of truth for "what's saved"; local state is the source of truth for "what the user sees right now".
This separation is intentional. If you bound the textarea directly to server state, every save + refetch would reset the cursor position and cause flicker. Local state gives you a stable editing surface.
Refs: Imperative State for Async Logic
The hook uses several refs to bridge the gap between React's render cycle and imperative async operations (debounce timers, mutation callbacks):
const initializedRef = useRef(false);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const contentRef = useRef("");
contentRef.current = content;
const isSavingRef = useRef(false);
isSavingRef.current = isSaving;
const needsReSaveRef = useRef(false);
const pendingTitleRef = useRef<string | null | undefined>(undefined);
const serverChecksumRef = useRef<string | null>(null);
const flushSaveRef = useRef<() => void>(() => {});
Why refs instead of state? The save logic runs inside mutation callbacks (onSettled) and debounce timers. These callbacks capture values from the render when they were created. If they read content or isSaving directly, they would see stale values. Refs provide a way to always read the latest value.
Note the assignment pattern: contentRef.current = content runs during the component body, not inside useEffect. This is safe because it's a ref assignment (no DOM mutation, no observable side effect). It guarantees the ref always holds the value from the most recent render.
Each ref's purpose:
| Ref | Purpose |
|---|---|
initializedRef |
Gate for one-time hydration from server to local state |
debounceTimerRef |
Handle for clearTimeout on timer reset or cleanup |
contentRef |
Latest content for reading inside callbacks without stale closure |
isSavingRef |
Latest isSaving for reading inside callbacks without stale closure |
needsReSaveRef |
Flag: a save was requested while another was in flight — flush immediately when it settles |
pendingTitleRef |
Queued title change that arrived while a save was in flight |
serverChecksumRef |
Imperative mirror of the server's checksum for dirty detection in callbacks |
flushSaveRef |
Breaks the circular useCallback dependency between flushSave and scheduleAutoSave
|
Dirty Detection: Reactive and Imperative
The hook tracks dirty state in two ways:
Reactive — for the UI (status badge, save button, navigation blocker):
const isDirty = useMemo(() => {
if (!serverDocument) return false;
const localChecksum = content ? computeChecksum(content) : null;
return localChecksum !== serverDocument.checksum;
}, [serverDocument, content]);
Imperative — for use inside callbacks and timers where React state may be stale:
const checkDirty = useCallback(() => {
const localChecksum = contentRef.current
? computeChecksum(contentRef.current)
: null;
return localChecksum !== serverChecksumRef.current;
}, []);
Why two? The reactive isDirty depends on serverDocument.checksum from the query cache, which updates asynchronously after a render cycle. Inside a mutation's onSettled callback, React hasn't re-rendered yet, so the reactive value would be stale. checkDirty() reads from serverChecksumRef, which is updated synchronously in the per-call onSuccess — making it safe to call from any callback.
computeChecksum is a thin wrapper around crypto-js:
// lib/checksum.ts
import CryptoJS from "crypto-js";
export function computeChecksum(text: string): string {
return CryptoJS.MD5(text).toString();
}
The same function is used everywhere: server-side when saving, in useSaveDocument's cache update, in reactive isDirty, and in imperative checkDirty(). This consistency guarantees checksums always match when content is the same.
The Status Enum
Rather than exposing isLoading, isSaving, and isDirty as three separate booleans, the hook derives a single status:
export type DocumentEditorStatus = "loading" | "idle" | "dirty" | "saving";
const status: DocumentEditorStatus = (() => {
if (isLoading) return "loading";
if (isSaving) return "saving";
if (isDirty) return "dirty";
return "idle";
})();
Priority order matters. The status enum has a strict precedence:
Consider what happens when a save is in flight and the user keeps typing:
-
isSavingistrue(mutation pending) -
isDirtymight betrue(user typed more after the save started)
We show "saving" because that's the most useful signal — the user should know their previous content is being saved. Once the save completes and the cache updates, isDirty will recalculate against the new server checksum. If the user typed more since the save started, it stays dirty and the debounce timer will pick it up.
This eliminates impossible states. With booleans, a component could accidentally check isDirty && isSaving and show confusing UI. With a single enum, you just switch on it. Note that isDirty is still exposed separately for the navigation blocker, which needs to know about unsaved changes regardless of save state.
The Core Save Dispatcher: flushSave
All saving flows through a single function — flushSave. This is the central dispatcher that handles queueing, merging, and retry logic:
const flushSave = useCallback(() => {
if (isSavingRef.current) {
needsReSaveRef.current = true;
return;
}
const payload = { id: documentId };
let hasWork = false;
if (checkDirty()) {
payload.content = contentRef.current || null;
hasWork = true;
}
if (pendingTitleRef.current !== undefined) {
payload.title = pendingTitleRef.current;
pendingTitleRef.current = undefined;
hasWork = true;
}
if (!hasWork) return;
needsReSaveRef.current = false;
saveDocument(payload, {
onSuccess: () => { /* update serverChecksumRef */ },
onSettled: () => { /* check for queued work */ },
});
}, [documentId, saveDocument, checkDirty, scheduleAutoSave]);
The flow:
flowchart TB
Entry["flushSave() called"] --> Saving{"Save in flight?"}
Saving -->|Yes| Queue["needsReSaveRef = true\n(wait for current save)"]
Saving -->|No| BuildPayload["Build payload"]
BuildPayload --> Dirty{"Content dirty?"}
Dirty -->|Yes| AddContent["Add content to payload"]
Dirty -->|No| CheckTitle{"Pending title?"}
AddContent --> CheckTitle
CheckTitle -->|Yes| AddTitle["Add title to payload"]
CheckTitle -->|No| HasWork{"Any work?"}
AddTitle --> HasWork
HasWork -->|No| Skip["Return (nothing to save)"]
HasWork -->|Yes| Send["saveDocument(payload)"]
Send --> OnSettled["onSettled: check for more work"]
Three key design choices:
Single request, merged payload: If both content and title need saving, they go in one PUT request instead of two. This halves the number of requests when both change.
No concurrent saves: If a save is already in flight,
flushSavejust setsneedsReSaveRef = trueand returns. It never fires a second concurrent request for the same document. This avoids race conditions where an older save could overwrite a newer one.Automatic retry via
onSettled: After every save completes (success or failure), the callback checks if more work accumulated during the flight.
The onSettled Callback: Immediate vs Debounced Retry
The most subtle part of the design is what happens after a save completes:
onSettled: () => {
setTimeout(() => {
if (needsReSaveRef.current || pendingTitleRef.current !== undefined) {
needsReSaveRef.current = false;
flushSaveRef.current();
return;
}
if (checkDirty()) {
scheduleAutoSave();
}
}, 0);
},
There are two distinct cases:
Explicit re-save (needsReSaveRef or pendingTitleRef) — the user clicked Save, blurred the textarea, or blurred the title while a save was in flight. They explicitly requested a save, so we flush immediately.
Passive dirty (checkDirty() only) — the user was typing during the save but never triggered an explicit save action. In this case we call scheduleAutoSave(), which resets the 2-second debounce timer. This prevents a rapid-fire loop: save completes → dirty → save → completes → dirty → save...
flowchart TB
Settled["Save completed (onSettled)"] --> Wait["setTimeout(0)\n(let React reconcile isPending)"]
Wait --> Explicit{"needsReSaveRef OR\npendingTitleRef?"}
Explicit -->|Yes| Immediate["flushSave() immediately\n(user requested this)"]
Explicit -->|No| Passive{"checkDirty()?"}
Passive -->|Yes| Debounce["scheduleAutoSave()\n(wait for user to stop typing)"]
Passive -->|No| Done["Done (everything saved)"]
The setTimeout(0) wrapper is essential. When onSettled fires, React hasn't yet processed the mutation state change (isPending: true → false). Without the timeout, flushSave() would read isSavingRef.current === true and queue instead of sending — creating an infinite loop. The macrotask gives React one tick to flush its state updates.
Auto-Save: Debounce Strategy
const AUTO_SAVE_DEBOUNCE_MS = 2_000;
Every keystroke resets a 2-second timer. When the timer fires, flushSave() sends the current content if dirty.
const scheduleAutoSave = useCallback(() => {
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = setTimeout(
() => flushSaveRef.current(),
AUTO_SAVE_DEBOUNCE_MS,
);
}, []);
scheduleAutoSave uses flushSaveRef.current() instead of flushSave directly. This breaks a circular dependency: flushSave depends on scheduleAutoSave (used in onSettled), and scheduleAutoSave would depend on flushSave. By going through the ref, scheduleAutoSave has an empty dependency array and a stable identity.
gantt
title Auto-Save Debounce: User types, pauses, types again
dateFormat ss
axisFormat %Ss
section User Activity
Typing :active, t1, 00, 5s
Pause :t2, 05, 4s
Typing again :active, t3, 09, 3s
Idle :t4, 12, 8s
section Debounce 2s
Timer resets each keystroke :done, d0, 00, 5s
Save 1 (2s after pause) :crit, d1, 07, 1s
Timer resets each keystroke :done, d2, 09, 3s
Save 2 (2s after stop) :crit, d3, 14, 1s
The debounce approach saves soon after the user pauses — typically within 2 seconds of stopping. During continuous typing, no saves are triggered. This keeps the save frequency proportional to the user's natural editing rhythm.
Title and Content: Unified Through flushSave
Both title and content saves flow through the same dispatcher:
const updateContent = useCallback(
(next: string) => {
setContent(next);
contentRef.current = next;
scheduleAutoSave();
},
[scheduleAutoSave],
);
const save = useCallback(() => {
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
flushSave();
}, [flushSave]);
const saveTitle = useCallback(
(newTitle: string) => {
setTitle(newTitle);
pendingTitleRef.current = newTitle.trim() || null;
flushSave();
},
[flushSave],
);
flowchart TB
subgraph triggers [Save Triggers]
direction TB
Keystroke["Keystroke → updateContent()"] --> Debounce["scheduleAutoSave()\n2s debounce timer"]
Debounce --> FlushSave
BlurTextarea["Blur textarea → save()"] --> FlushSave
ClickSave["Click Save → save()"] --> FlushSave
BlurTitle["Blur title → saveTitle()"] --> PendingTitle["pendingTitleRef = value"]
PendingTitle --> FlushSave
end
FlushSave["flushSave()\ncentral dispatcher"]
FlushSave --> API["PUT /documents/:id\n(merged payload)"]
saveTitle doesn't call saveDocument directly. It queues the title into pendingTitleRef and delegates to flushSave. This means:
- If no save is in flight,
flushSavepicks up the pending title (and any dirty content) and sends one merged request. - If a save is in flight,
flushSavemarksneedsReSaveRef = true. When the current save completes,onSettledsees the flag and flushes again — picking up the queued title.
This unified approach eliminates code duplication and ensures title + content saves never race against each other.
Lifecycle: Two useEffect Hooks
flowchart TB
subgraph Effect1 ["Effect 1: Reset + cleanup"]
DocIdChange["documentId changes"] --> ResetRefs["Reset refs:\ninitializedRef, needsReSaveRef,\npendingTitleRef, serverChecksumRef"]
DocIdChange -.->|"cleanup (on change / unmount)"| ClearTimer["clearTimeout(debounceTimer)"]
end
subgraph Effect2 ["Effect 2: One-time hydration"]
ServerLoaded["serverDocument loaded"] --> CheckInit{"initializedRef?"}
CheckInit -->|false| Hydrate["setTitle, setContent from server\nSync contentRef, serverChecksumRef\ninitializedRef = true"]
CheckInit -->|true| SkipHydrate["skip (already hydrated)"]
end
Effect1 --> Effect2
Effect 1 — Reset on document switch + cleanup on unmount:
useEffect(() => {
initializedRef.current = false;
needsReSaveRef.current = false;
pendingTitleRef.current = undefined;
serverChecksumRef.current = null;
return () => {
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
};
}, [documentId]);
When documentId changes, the setup resets all refs for the new document. The cleanup function clears any pending debounce timer — this runs both when switching documents (cleanup of the previous effect) and on unmount.
Effect 2 — One-time hydration:
useEffect(() => {
if (serverDocument && !initializedRef.current) {
setTitle(serverDocument.title ?? "");
setContent(serverDocument.content ?? "");
contentRef.current = serverDocument.content ?? "";
serverChecksumRef.current = serverDocument.checksum;
initializedRef.current = true;
}
}, [serverDocument]);
When the server document arrives, hydrate local state once. The initializedRef gate prevents subsequent query refetches from overwriting the user's in-progress edits. Note that contentRef and serverChecksumRef are also initialized here — this ensures checkDirty() works correctly from the very first callback invocation.
End-to-End Timeline
Here's the complete flow from opening a document through auto-save and concurrent editing:
sequenceDiagram
participant U as User
participant C as DocumentEditor
participant H as useDocumentEditor
participant Q as TanStack Query Cache
participant S as Server
U->>C: Navigate to /editor/documents/abc
C->>H: useDocumentEditor({ documentId: "abc" })
H->>S: GET /documents/abc
Note over H: status = "loading"
S-->>Q: { id, title, content, checksum }
Q-->>H: serverDocument ready
H->>H: Hydrate title + content + serverChecksumRef
Note over H: status = "idle"
U->>C: Types "Hello world"
C->>H: updateContent("Hello world")
H->>H: setState + contentRef + reset debounce timer
Note over H: status = "dirty"
Note over H: 2s debounce fires
H->>S: PUT /documents/abc { content: "Hello world" }
Note over H: status = "saving"
U->>C: Types " and goodbye" (during save)
C->>H: updateContent("Hello world and goodbye")
H->>H: scheduleAutoSave (new 2s timer)
Note over H: needsReSaveRef still false (debounce handles it)
S-->>H: 200 OK
H->>Q: setQueryData: update checksum
H->>H: serverChecksumRef = checksum("Hello world")
H->>H: onSettled: checkDirty? Yes → scheduleAutoSave()
Note over H: User stops typing, 2s debounce fires
H->>S: PUT /documents/abc { content: "Hello world and goodbye" }
Note over H: status = "saving"
S-->>H: 200 OK
H->>H: onSettled: checkDirty? No → done
Note over H: status = "idle"
The DocumentEditor Component
The component is intentionally thin. All logic lives in the hook; the component just wires it to UI elements:
// features/editor/components/document-editor.tsx
export function DocumentEditor({ documentId }: { documentId: string }) {
const { title, setTitle, content, updateContent, status, isDirty, save, saveTitle } =
useDocumentEditor({ documentId });
useBlocker({
shouldBlockFn: () => {
if (!isDirty) return false;
return !confirm("You have unsaved changes. Are you sure you want to leave?");
},
enableBeforeUnload: isDirty,
});
if (status === "loading") {
return (
<div className="flex items-center justify-center py-16">
<Loader2Icon className="size-6 animate-spin" />
</div>
);
}
const isSaving = status === "saving";
return (
<div className="flex flex-col gap-8">
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
onBlur={() => saveTitle(title)}
onKeyDown={(e) => { if (e.key === "Enter") e.currentTarget.blur(); }}
placeholder="Untitled"
className="border-none px-0 text-3xl font-light tracking-tight shadow-none focus-visible:ring-0"
/>
<Textarea
value={content}
onChange={(e) => updateContent(e.target.value)}
onBlur={save}
placeholder="Start typing..."
className="min-h-[280px] resize-none border-none px-0 shadow-none focus-visible:ring-0"
/>
<div className="flex items-center justify-between gap-4 border-t border-border/60 pt-6">
<StatusIndicator status={status} />
<div className="flex items-center gap-2">
<DeleteDocumentDialog documentId={documentId} disabled={isSaving} />
<Button variant="ghost" size="sm" onClick={save} disabled={isSaving || !isDirty}>
{isSaving ? <Loader2Icon className="size-4 animate-spin" /> : <SaveIcon className="size-4" />}
Save
</Button>
</div>
</div>
</div>
);
}
A few things to note:
- Input and Textarea are never disabled — saves are background operations. The user can keep typing seamlessly even while a save is in flight. This is a deliberate UX choice: the editor should never interrupt the user's flow.
-
Title saves on blur, which is triggered either by clicking away or pressing Enter (the keydown handler calls
blur()). It flows throughflushSavefor automatic queueing. -
Content saves on blur via
onBlur={save}. This covers the case where the user clicks away without waiting for auto-save. -
Save button is disabled when saving or when not dirty. The
isDirtycheck (notstatus !== "dirty") ensures correctness even during save state. -
Delete is handled by
DeleteDocumentDialog— a confirmation dialog that redirects to the document list after deletion.
Navigation Safety with useBlocker
Auto-save handles persistence, but what if the user navigates away before a save happens? They'd lose their work silently. TanStack Router provides useBlocker to intercept navigation attempts and give the user a chance to stay.
useBlocker({
shouldBlockFn: () => {
if (!isDirty) return false;
return !confirm("You have unsaved changes. Are you sure you want to leave?");
},
enableBeforeUnload: isDirty,
});
The blocker uses isDirty (the reactive boolean) rather than status. This is important: if a save is in flight and the user edited more content after the save started, status would show "saving" — but there are still unsaved changes. Using isDirty catches this case.
useBlocker covers two distinct navigation scenarios:
In-app navigation — clicking a link or calling router.navigate() within the SPA. When a route transition is attempted, shouldBlockFn runs. If isDirty is true, the user sees a confirmation dialog.
External navigation — closing the tab, refreshing the page, or typing a new URL. These bypass the SPA router entirely, so shouldBlockFn can't catch them. Instead, enableBeforeUnload: isDirty registers a beforeunload event listener that triggers the browser's native "Changes you made may not be saved" dialog.
flowchart TB
NavAttempt{"Navigation attempt"} --> Type{"Type?"}
Type -->|"In-app\n(link click, router.navigate)"| DirtyCheck{"isDirty?"}
DirtyCheck -->|No| Allow1["Allow navigation"]
DirtyCheck -->|Yes| Confirm["confirm() dialog"]
Confirm -->|OK| Allow2["Allow: user chose to leave"]
Confirm -->|Cancel| Block["Block: stay on page"]
Type -->|"External\n(tab close, refresh, new URL)"| BeforeUnload{"enableBeforeUnload\n= isDirty?"}
BeforeUnload -->|true| BrowserDialog["Browser's native\n'leave page?' dialog"]
BeforeUnload -->|false| Allow3["Allow: no listener registered"]
StatusIndicator
A simple switch on the status enum:
// features/editor/components/status-indicator.tsx
export function StatusIndicator({ status }: { status: DocumentEditorStatus }) {
switch (status) {
case "saving":
return (
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2Icon className="size-3 animate-spin" />
Saving...
</span>
);
case "dirty":
return (
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<CircleDotIcon className="size-3" />
Unsaved
</span>
);
case "idle":
return (
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<CheckCircle2Icon className="size-3" />
Saved
</span>
);
default:
return null;
}
}
Because status is a single enum, there's no risk of showing "Saved" while simultaneously being dirty. The priority ordering in the hook guarantees exactly one state at a time. The UI uses plain text (text-xs text-muted-foreground) instead of badges for a minimal look.
Full Architecture
flowchart TB
subgraph Route [Route Layer]
EditorDoc["/editor/documents/$id"]
end
subgraph Component [Component Layer]
direction TB
DocEditor[DocumentEditor]
DocEditor --> StatusInd[StatusIndicator]
DocEditor --> Blocker["useBlocker (isDirty)"]
end
subgraph Hook [useDocumentEditor]
direction TB
LocalState["useState: title, content"]
LocalState --> DirtyCheck["isDirty = localChecksum ≠ serverChecksum"]
DirtyCheck --> StatusEnum["status: loading | saving | dirty | idle"]
ServerState["useGetDocument / useSaveDocument"]
RefsBlock["Refs: contentRef, isSavingRef,\nneedsReSaveRef, pendingTitleRef,\nserverChecksumRef, flushSaveRef"]
FlushSave["flushSave: central dispatcher"]
Debounce["scheduleAutoSave: 2s debounce"]
Debounce --> FlushSave
FlushSave --> ServerState
FlushSave -->|"onSettled: passive dirty"| Debounce
end
subgraph QueryCache [TanStack Query Cache]
direction TB
DocCache["cache: document, id"]
DocCache --> ListCache["cache: documents"]
end
subgraph API [API Endpoints]
direction TB
GET_ONE["GET /documents/:id"]
GET_ONE --> PUT["PUT /documents/:id"]
PUT --> GET_ALL["GET /documents"]
GET_ALL --> POST["POST /documents"]
POST --> DEL["DELETE /documents/:id"]
end
Route --> Component
Component --> Hook
DirtyCheck -->|"reads checksum"| DocCache
ServerState -->|"fetch"| GET_ONE
ServerState -->|"save"| PUT
ServerState -->|"setQueryData"| DocCache
ServerState -->|"invalidateQueries"| ListCache
Tradeoffs and Limitations
Debounce timing: The 2-second debounce means saves happen within 2 seconds of the user pausing. This is responsive for most editing, but if you need near-instant persistence (e.g., collaborative editing), you'd need a different approach (WebSocket-based sync, CRDTs).
Checksum cost: MD5 runs inside useMemo on every content change, and again imperatively in checkDirty() and flushSave. For typical document sizes (under 100KB), this is sub-millisecond. For megabyte-scale content, consider a faster hash (e.g., xxHash via WASM) or debouncing the checksum computation itself.
Dual dirty tracking: The hook maintains both a reactive isDirty (via useMemo + query cache) and an imperative checkDirty() (via serverChecksumRef). This is inherent complexity from needing dirty status both in the render cycle (for UI) and in async callbacks (for save logic). The two are kept in sync: serverChecksumRef is updated in onSuccess, and the query cache is updated by the mutation-level onSuccess in useSaveDocument.
No optimistic UI for content: The mutation doesn't use TanStack Query's onMutate for optimistic updates — it updates the cache in onSuccess. This means isDirty stays true during the save (and status shows "saving", not "idle"). If you wanted the badge to show "Saved" immediately on save (before the server responds), you'd move the cache update to onMutate and add onError rollback.
Single-editor assumption: There's no conflict resolution. If two tabs edit the same document, the last save wins. Adding conflict detection would require comparing checksums on the server side during PUT and returning a 409 if the checksum doesn't match.
No offline support: If the network drops, saves fail silently (TanStack Query's mutation will retry by default, but there's no explicit offline queue). For offline-first editing, you'd need a local persistence layer (e.g., IndexedDB) and a sync mechanism.
Summary
The useDocumentEditor hook is where all the complexity lives — and that's by design. It encapsulates dirty detection (dual reactive/imperative checksum comparison), auto-save scheduling (debounce timer), non-blocking save queueing (flushSave dispatcher with needsReSaveRef), and status derivation (single enum) into one unit. The component layer stays thin: read values, call actions, render based on status — and never disable the editing surface. The server communication is abstracted behind TanStack Query hooks with cache updates that keep dirty detection working without refetches.





Top comments (0)