DEV Community

Isaac FEI
Isaac FEI

Posted on

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

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

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;
};
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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,
  });
}
Enter fullscreen mode Exit fullscreen mode

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"] });
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

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("");
Enter fullscreen mode Exit fullscreen mode

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>(() => {});
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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;
}, []);
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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";
})();
Enter fullscreen mode Exit fullscreen mode

Priority order matters. The status enum has a strict precedence:

Consider what happens when a save is in flight and the user keeps typing:

  1. isSaving is true (mutation pending)
  2. 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.

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]);
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

Three key design choices:

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

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

  3. 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);
},
Enter fullscreen mode Exit fullscreen mode

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)"]
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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,
  );
}, []);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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],
);
Enter fullscreen mode Exit fullscreen mode
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)"]
Enter fullscreen mode Exit fullscreen mode

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.

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
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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.

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,
});
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)