import { useEffect, useState } from "react";
export function useDebouncedValue<T>(value: T, delay = 800): T {
const [debounced, setDebounced] = useState<T>(value);
useEffect(() => {
const handle = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(handle);
}, [value, delay]);
return debounced;
}
import { useEffect, useRef } from "react";
import { useMutation } from "@tanstack/react-query";
import { useDebouncedValue } from "./useDebouncedValue";
import { saveDraft } from "./draftApi";
export type SaveStatus = "idle" | "unsaved" | "saving" | "saved" | "error";
interface AutosaveResult {
status: SaveStatus;
saveNow: () => void;
}
export function useAutosaveDraft(docId: string, content: string): AutosaveResult {
const debounced = useDebouncedValue(content, 800);
const lastSaved = useRef<string | null>(null);
const seqRef = useRef(0);
const latestSeq = useRef(0);
const mutation = useMutation({
mutationFn: (payload: { body: string; seq: number }) =>
saveDraft(docId, payload.body).then(() => payload),
onSuccess: (payload) => {
if (payload.seq < latestSeq.current) return; // stale response, ignore
latestSeq.current = payload.seq;
lastSaved.current = payload.body;
},
});
useEffect(() => {
if (debounced === lastSaved.current) return;
const seq = ++seqRef.current;
mutation.mutate({ body: debounced, seq });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debounced]);
const dirty = content !== lastSaved.current;
let status: SaveStatus = "idle";
if (mutation.isPending) status = "saving";
else if (mutation.isError) status = "error";
else if (dirty) status = lastSaved.current === null ? "unsaved" : "unsaved";
else if (lastSaved.current !== null) status = "saved";
const saveNow = () => {
if (content === lastSaved.current) return;
const seq = ++seqRef.current;
mutation.mutate({ body: content, seq });
};
return { status, saveNow };
}
import { useState } from "react";
import { useAutosaveDraft, SaveStatus } from "./useAutosaveDraft";
const LABELS: Record<SaveStatus, string> = {
idle: "",
unsaved: "Unsaved changes",
saving: "Saving\u2026",
saved: "All changes saved",
error: "Save failed \u2014 retrying on next edit",
};
function StatusBadge({ status }: { status: SaveStatus }) {
return <span className={`badge badge--${status}`}>{LABELS[status]}</span>;
}
export function DraftEditor({ docId, initial }: { docId: string; initial: string }) {
const [content, setContent] = useState(initial);
const { status, saveNow } = useAutosaveDraft(docId, content);
return (
<div className="draft-editor">
<header className="draft-editor__bar">
<StatusBadge status={status} />
<button type="button" onClick={saveNow} disabled={status === "saving"}>
Save now
</button>
</header>
<textarea
className="draft-editor__body"
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Start writing\u2026"
/>
</div>
);
}
export async function saveDraft(docId: string, body: string): Promise<void> {
const res = await fetch(`/api/documents/${docId}/draft`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body }),
});
if (!res.ok) {
throw new Error(`Autosave failed with status ${res.status}`);
}
}
Autosaving a draft is deceptively tricky: firing a network request on every keystroke floods the server and races responses, while saving too rarely risks losing work. This snippet coordinates three pieces — a debounced value hook, a mutation-driven autosave hook, and a small editor component with a live status badge — to get the balance right.
In useDebouncedValue hook, the raw editor content is passed through a setTimeout-based debounce. The effect clears the previous timer on every change, so the debounced value only settles once the user pauses typing for delay milliseconds. This is the classic debounce trade-off: latency in exchange for far fewer writes. Returning a plain value (not a callback) keeps the consuming component declarative — downstream effects simply react to the settled value.
useAutosaveDraft hook wires that debounced value into a React Query useMutation. It tracks a lastSaved ref so it can skip redundant saves when the content has not actually changed since the last successful write — an important guard, because effects can re-run for reasons unrelated to content. The mutation carries a monotonically increasing seq and stores the latest in latestSeq, so a slow earlier response cannot overwrite a newer one in onSuccess — this defeats the out-of-order response race that naive autosave code suffers from. The hook derives a single SaveStatus union (idle, unsaved, saving, saved, error) rather than exposing raw booleans, which keeps rendering logic trivial and unambiguous.
DraftEditor component composes the two hooks. It holds the live content in local state for instant typing feedback, feeds it to useDebouncedValue, and hands the settled value to useAutosaveDraft. The StatusBadge maps each status to human text, and a manual save button lets users force a flush via saveNow, disabled while a request is in flight.
The pattern shines for editors, note apps, and settings pages where losing input is unacceptable. Watch the pitfalls it addresses: debounce plus a dirty check to avoid needless traffic, sequence guarding to survive concurrency, and a derived status enum so the UI never lies about whether work is persisted.
Related snips
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form"]
static values = { delay: { type: Number, default: 250 } }
Debounced live search with Stimulus + Turbo Streams
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
timeout: 15000,
Axios API client with interceptors
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface FilterState {
search: string
category: string | null
Zustand for lightweight state management
import SwiftUI
struct ContentView: View {
@State private var username = ""
@State private var isLoggedIn = false
@StateObject private var viewModel = LoginViewModel()
SwiftUI declarative UI with state management
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Form Validation Example</title>
<style>
HTML forms with validation and accessibility
import { Application } from "@hotwired/stimulus"
import FormSubmitController from "./controllers/form_submit_controller"
const application = Application.start()
application.debug = false
Disable submit button while Turbo form is submitting
Share this code
Here's the card — post it anywhere.