Implementing a Simple Text Editor with Auto-Save Using TanStack Start

Published: (March 20, 2026 at 12:32 AM EDT)
22 min read
Source: Dev.to

Source: Dev.to

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. 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, beforeunload on 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. Before diving into code, here’s the auto-save strategy at a glance: sequenceDiagram participant User participant Hook as useDocumentEditor participant Timer as Debounce Timer (2s) participant Server as PUT /documents/:id

User->>Hook: types in textarea
Hook->>Hook: updateContent() - set state + reset timer
Hook->>Hook: isDirty = true (checksum mismatch)

User->>Hook: stops typing
Note over Timer: 2s elapses

Timer->>Hook: flushSave()
Hook->>Server: saveDocument(content)

User->>Hook: types more while save is in flight
Hook->>Hook: queue re-save (needsReSaveRef = true)

Server-->>Hook: 200 OK
Hook->>Hook: update serverChecksumRef
Hook->>Hook: onSettled: still dirty? → scheduleAutoSave()
Note over Timer: wait for user to stop, then save again

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. 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. Two routes, file-based: flowchart TB EditorList[“/editor\n(document list)”] —>|“click document”| EditorDoc[“/editor/documents/$id\n(editor)”] EditorList —>|“click New Document”| CreateAPI[“POST /documents”] —>|“redirect”| EditorDoc EditorDoc —>|“click Delete”| DeleteAPI[“DELETE /documents/:id”] —>|“redirect”| EditorList

The editor page extracts id from the URL and passes it as a prop:

export const Route = createFileRoute("/_main/editor/documents/$id")({
function DocumentEditorPage() {

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").
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", variables.id], (old) => {
        if (!old) return old;
        const updates: Partial = {};
        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:
flowchart TB
    A["User types"] --> B["Local checksum changes"]
    B --> C["isDirty = true"]
    C --> D["Debounce fires / manual save"]
    D --> E["PUT /documents/:id"]
    E --> F["onSuccess: setQueryData\n(recompute server checksum)"]
    F --> G["Server checksum = local checksum"]
    G --> H["isDirty = false"]
    H -.->|"user types again"| A

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.
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 | null>(null);
const contentRef = useRef("");
contentRef.current = content;
const isSavingRef = useRef(false);
isSavingRef.current = isSaving;
const needsReSaveRef = useRef(false);
const pendingTitleRef = useRef(undefined);
const serverChecksumRef = useRef(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.
flowchart TB
    subgraph everyRender [Every Render]
        ContentState["content (state)"] -->|"mirror"| ContentRef["contentRef.current"]
        IsSavingState["isSaving (from mutation)"] -->|"mirror"| IsSavingRef["isSavingRef.current"]
    end

    subgraph asyncCallbacks [Debounce Timer / onSettled Callback]
        ContentRef -->|"read latest"| FlushSave["flushSave: build payload"]
        IsSavingRef -->|"read latest"| SaveGuard{"isSavingRef.current?"}
        SaveGuard -->|yes| Queue["queue: needsReSaveRef = true"]
        SaveGuard -->|no| FlushSave
    end

    everyRender --> asyncCallbacks

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 immedi

ately 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

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.
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:
stateDiagram-v2
    [*] --> loading : component mounts
    loading --> idle : server document fetched, not dirty
    loading --> dirty : server document fetched, already dirty
    idle --> dirty : user types (checksum mismatch)
    dirty --> saving : save triggered (manual / blur / auto)
    saving --> idle : save succeeds, no new edits
    saving --> dirty : save succeeds, user typed during save
    dirty --> idle : user undoes changes (checksum matches again)

Consider what happens when a save is in flight and the user keeps typing:
isSaving is true (mutation pending)
isDirty might be true (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.
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, flushSave just sets needsReSaveRef = true and 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:
E

xplicit 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.
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.
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, flushSave picks up the pending title (and any dirty content) and sends one merged request.
If a save is in flight, flushSave marks needsReSaveRef = true. When the current save completes, onSettled sees 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.
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 loca

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

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 (
      
        
      
    );
  }

  const isSaving = status === "saving";

  return (
    
       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"
      />

       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"
      />

      
        
        
          
          
            {isSaving ?  : }
            Save
          
        
      
    
  );
}

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 through flushSave for 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 isDirty check (not status !== "dirty") ensures correctness even during save state.
Delete is handled by DeleteDocumentDialog — a confirmation dialog that redirects to the document list after deletion.
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(lin

k 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 (
        
          
          Saving...
        
      );
    case "dirty":
      return (
        
          
          Unsaved
        
      );
    case "idle":
      return (
        
          
          Saved
        
      );
    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.
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

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.
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.
0 views
Back to Blog

Related posts

Read more »