# 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**](https://tanstack.com/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](https://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:

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

## 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:

```ts
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](https://tanstack.com/router/latest/docs/framework/react/guide/file-based-routing):

```mermaid
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:

```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 <DocumentEditor documentId={id} />;
}
```

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`](https://tanstack.com/query/latest/docs/framework/react/reference/useQuery) for fetching and one [`useMutation`](https://tanstack.com/query/latest/docs/framework/react/reference/useMutation) for saving:

```ts
const { mutate: saveDocument, isPending: isSaving } = useSaveDocument();
const { data: serverDocument, isLoading } = useGetDocument(documentId);
```

`useGetDocument` is a standard `useQuery`:

```ts
// 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`](https://tanstack.com/query/latest/docs/reference/QueryClient#queryclientsetquerydata) — including recomputing the checksum — so the UI immediately reflects the saved state without a refetch:

```ts
// 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:

```mermaid
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
```

### Local State: The Editable Copy

```ts
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):

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

```mermaid
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 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):

```ts
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:

```ts
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`](https://www.npmjs.com/package/crypto-js):

```ts
// 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:

```ts
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:

```mermaid
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:

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:

```ts
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:

```mermaid
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:

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:

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

```mermaid
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

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

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

```mermaid
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:

```ts
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],
);
```

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

### Lifecycle: Two `useEffect` Hooks

```mermaid
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:**

```ts
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:**

```ts
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:

```mermaid
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:

```tsx
// 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 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](https://tanstack.com/router) provides [`useBlocker`](https://tanstack.com/router/latest/docs/framework/react/api/router/useBlockerHook) to intercept navigation attempts and give the user a chance to stay.

```tsx
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`](https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event) event listener that triggers the browser's native "Changes you made may not be saved" dialog.

```mermaid
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:

```tsx
// 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

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