React Form Handling: Debounced Validation, Auto-Save Drafts, and Controlled Inputs
Forms are the most rewritten part of every React app. They look easy on day one — slap a <input>, wire onChange to useState, ship it. By month three, the same form has grown async username validation, an auto-saved draft, a custom date popover, and a controlled-or-uncontrolled toggle that has to play nicely with the design system. Each of those features pulls in its own ad-hoc state machine, its own effect cleanup, and its own pile of edge cases. The form file becomes the longest in the codebase, and nobody on the team wants to touch it.
This post walks through four primitives that every non-trivial form needs eventually: a debounced value to throttle async validation, a controlled-or-uncontrolled wrapper that lets a component accept either pattern, a localStorage-backed draft that survives refreshes, and a click-outside detector that closes popovers without leaking listeners. For each one we will build the manual version first so the trade-offs are visible, then swap it for a focused hook from ReactUse. At the end, we combine all four into a single account-settings form that validates as you type, auto-saves drafts, and handles a country-picker popover.
1. Debounced Async Validation
The Manual Way
The classic mistake in async validation is firing a request on every keystroke. The classic fix is setTimeout, and the classic bug is forgetting to clear the previous timer:
import { useEffect, useState } from "react";
function ManualUsernameField() {
const [username, setUsername] = useState("");
const [debounced, setDebounced] = useState("");
const [status, setStatus] = useState<"idle" | "checking" | "ok" | "taken">("idle");
useEffect(() => {
const id = setTimeout(() => setDebounced(username), 400);
return () => clearTimeout(id);
}, [username]);
useEffect(() => {
if (!debounced) {
setStatus("idle");
return;
}
let cancelled = false;
setStatus("checking");
fetch(`/api/username?u=${encodeURIComponent(debounced)}`)
.then((r) => r.json())
.then((data) => {
if (!cancelled) setStatus(data.available ? "ok" : "taken");
});
return () => {
cancelled = true;
};
}, [debounced]);
return (
<label>
Username
<input value={username} onChange={(e) => setUsername(e.target.value)} />
<span>{status}</span>
</label>
);
}
There are two effects here doing two different jobs, and they have to stay in sync. The first one is a debouncer: turn rapid username changes into a single delayed debounced value. The second one is a request runner: when debounced changes, fire a request and ignore the result if a newer one comes back. Both effects need their own cleanup. If you forget the clearTimeout you double up requests; if you forget the cancelled flag you race and the older response can overwrite the newer one.
The real cost is not lines of code — it is that the debounce logic is welded to this particular field. Reuse it on the email field and you copy-paste the same five lines.
The ReactUse Way: useDebounce
useDebounce returns a value that lags behind its input by a fixed delay:
import { useEffect, useState } from "react";
import { useDebounce } from "@reactuses/core";
function UsernameField() {
const [username, setUsername] = useState("");
const debounced = useDebounce(username, 400);
const [status, setStatus] = useState<"idle" | "checking" | "ok" | "taken">("idle");
useEffect(() => {
if (!debounced) {
setStatus("idle");
return;
}
let cancelled = false;
setStatus("checking");
fetch(`/api/username?u=${encodeURIComponent(debounced)}`)
.then((r) => r.json())
.then((data) => {
if (!cancelled) setStatus(data.available ? "ok" : "taken");
});
return () => {
cancelled = true;
};
}, [debounced]);
return (
<label>
Username
<input value={username} onChange={(e) => setUsername(e.target.value)} />
<span>{status}</span>
</label>
);
}
The first effect — the debouncing one — is gone. useDebounce owns the timer and its cleanup. What remains is the part that is genuinely about your form: when the debounced value changes, run a validation request and discard stale responses.
The hook also pairs naturally with the equivalent function-debouncer, useDebounceFn, when you want to debounce an event handler instead of a value — useful for "save on blur" and similar patterns where the trigger is a callback rather than a state change.
2. Controlled or Uncontrolled — Pick One, Support Both
The Manual Way
Library components have a recurring dilemma: should the consumer pass value and onChange, or should the component manage its own state with a defaultValue? The honest answer is "both, depending on who's calling." Most teams reinvent this pattern field by field:
function ManualToggle({
value,
defaultValue = false,
onChange,
}: {
value?: boolean;
defaultValue?: boolean;
onChange?: (next: boolean) => void;
}) {
const isControlled = value !== undefined;
const [internal, setInternal] = useState(defaultValue);
const current = isControlled ? value : internal;
const handleClick = () => {
const next = !current;
if (!isControlled) setInternal(next);
onChange?.(next);
};
return (
<button role="switch" aria-checked={current} onClick={handleClick}>
{current ? "On" : "Off"}
</button>
);
}
The pattern is simple in isolation but a magnet for subtle bugs. What if the consumer flips between controlled and uncontrolled by passing undefined mid-render? What if they pass value but no onChange? React's own form inputs warn about both of these, but custom components rarely bother — and as your design system grows, the boilerplate is duplicated across every input, switch, slider, and date picker.
The ReactUse Way: useControlled
useControlled collapses the whole pattern into a single hook call:
import { useControlled } from "@reactuses/core";
function Toggle({
value,
defaultValue = false,
onChange,
}: {
value?: boolean;
defaultValue?: boolean;
onChange?: (next: boolean) => void;
}) {
const [current, setCurrent] = useControlled({
value,
defaultValue,
onChange,
});
return (
<button
role="switch"
aria-checked={current}
onClick={() => setCurrent(!current)}
>
{current ? "On" : "Off"}
</button>
);
}
The hook does three things you would otherwise write yourself:
- Decides controlled vs uncontrolled once, on the first render, and warns if the mode flips later — same diagnostic React's built-in inputs use.
-
Returns a stable setter that internally branches on the mode: in uncontrolled mode it updates internal state; in controlled mode it just calls
onChangeand lets the parent re-render. -
Always reflects the latest source of truth. The first tuple element is
valuewhen controlled and the internal state when not, so consumers never see a mismatch.
You can drop this into any input-shaped component in your design system and stop thinking about the pattern.
3. Auto-Saving Form Drafts
The Manual Way
Long forms — onboarding flows, settings pages, content editors — should never lose a user's work to a refresh. The standard fix is to mirror the form state into localStorage, and the standard mistake is to do it on every keystroke:
function ManualDraftForm() {
const [draft, setDraft] = useState(() => {
if (typeof window === "undefined") return { title: "", body: "" };
const raw = localStorage.getItem("post-draft");
return raw ? JSON.parse(raw) : { title: "", body: "" };
});
useEffect(() => {
localStorage.setItem("post-draft", JSON.stringify(draft));
}, [draft]);
return (
<form>
<input
value={draft.title}
onChange={(e) => setDraft((d) => ({ ...d, title: e.target.value }))}
/>
<textarea
value={draft.body}
onChange={(e) => setDraft((d) => ({ ...d, body: e.target.value }))}
/>
</form>
);
}
Three problems hide in those fifteen lines. First, the lazy initializer reads localStorage on every mount but does not re-read if another tab updated it — multi-tab editing breaks silently. Second, the JSON.parse will throw on corrupted data and crash the component on mount. Third, localStorage.setItem is synchronous and runs on every render, which on a fast-typing user can pin the main thread.
The SSR check at the top is the giveaway that this is a recipe other components in your codebase will copy and probably get wrong.
The ReactUse Way: useLocalStorage
useLocalStorage looks like useState and behaves like useState, but the value lives in storage:
import { useLocalStorage } from "@reactuses/core";
function DraftForm() {
const [draft, setDraft] = useLocalStorage("post-draft", {
title: "",
body: "",
});
return (
<form>
<input
value={draft.title}
onChange={(e) => setDraft({ ...draft, title: e.target.value })}
/>
<textarea
value={draft.body}
onChange={(e) => setDraft({ ...draft, body: e.target.value })}
/>
</form>
);
}
The hook handles the four things the manual version got wrong or skipped:
- SSR-safe initialization. On the server it returns the default; the stored value hydrates on the first client render without a mismatch.
-
Cross-tab sync. Listens to
storageevents and updates state when another tab writes to the same key. - JSON-safe. Catches parse errors and falls back to the default rather than crashing.
-
Stable setter. The returned setter has a stable identity, so you can pass it to
useEffectdeps or memoized children without churn.
For really long forms you often want auto-save plus debounce. Combine with useDebounce from section 1 — debounce the form state, then write the debounced value to storage — and you get an editor that survives refreshes without thrashing the disk.
4. Closing Popovers with Click-Outside
The Manual Way
Country pickers, date pickers, autocomplete menus, and anything else that floats above the page have to close when the user clicks elsewhere. The textbook implementation listens on document:
function ManualPopover({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
return (
<div ref={ref} style={{ position: "relative" }}>
<button onClick={() => setOpen((v) => !v)}>Toggle</button>
{open && <div className="popover">{children}</div>}
</div>
);
}
This works for the simple case and breaks the moment your popover renders into a portal. The ref.current.contains(...) check assumes the popover is a DOM descendant of the trigger, which it usually is not for a real design system — popovers escape overflow containers by mounting at the body root. You also have to decide between mousedown and click (the right answer is almost always mousedown, so the popover closes before a downstream click handler fires), and remember to skip the listener when closed to avoid wasting cycles on every page click.
The ReactUse Way: useClickOutside
useClickOutside accepts a ref (or list of refs) and a handler:
import { useRef, useState } from "react";
import { useClickOutside } from "@reactuses/core";
function Popover({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLDivElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
useClickOutside([triggerRef, popoverRef], () => setOpen(false));
return (
<>
<div ref={triggerRef}>
<button onClick={() => setOpen((v) => !v)}>Toggle</button>
</div>
{open && (
<div ref={popoverRef} className="popover">
{children}
</div>
)}
</>
);
}
The list-of-refs form is what makes this work for portaled popovers: you mark both the trigger and the floating panel as "inside," and clicks anywhere else fire the handler. The hook also handles the mousedown choice for you, attaches the listener once at the document level (no per-component churn), and cleans up on unmount.
There is a closely related hook, useClickAway, with a slightly different API for cases where you only need a single target ref — pick whichever reads better in your component.
Putting It All Together: A Settings Form
Here is a complete account-settings form that uses all four hooks. The username validates as you type. The whole form auto-saves to localStorage. The notifications switch is a controlled-or-uncontrolled component. The country picker is a portal-friendly popover that closes on outside click.
import { useEffect, useRef, useState } from "react";
import {
useDebounce,
useControlled,
useLocalStorage,
useClickOutside,
} from "@reactuses/core";
interface Settings {
username: string;
country: string;
notifications: boolean;
}
const COUNTRIES = ["United States", "Japan", "Germany", "Brazil", "India"];
function NotificationSwitch({
value,
defaultValue = true,
onChange,
}: {
value?: boolean;
defaultValue?: boolean;
onChange?: (next: boolean) => void;
}) {
const [on, setOn] = useControlled({ value, defaultValue, onChange });
return (
<button
type="button"
role="switch"
aria-checked={on}
onClick={() => setOn(!on)}
style={{
width: 48,
height: 24,
borderRadius: 999,
border: "none",
background: on ? "#3b82f6" : "#cbd5e1",
position: "relative",
cursor: "pointer",
}}
>
<span
style={{
position: "absolute",
top: 2,
left: on ? 26 : 2,
width: 20,
height: 20,
borderRadius: "50%",
background: "white",
transition: "left 120ms ease",
}}
/>
</button>
);
}
function CountryPicker({
value,
onChange,
}: {
value: string;
onChange: (next: string) => void;
}) {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLUListElement>(null);
useClickOutside([triggerRef, menuRef], () => setOpen(false));
return (
<div style={{ position: "relative", display: "inline-block" }}>
<button
ref={triggerRef}
type="button"
onClick={() => setOpen((v) => !v)}
style={{
padding: "6px 12px",
borderRadius: 6,
border: "1px solid #cbd5e1",
background: "white",
cursor: "pointer",
}}
>
{value || "Select country"} ▾
</button>
{open && (
<ul
ref={menuRef}
style={{
position: "absolute",
top: "calc(100% + 4px)",
left: 0,
margin: 0,
padding: 4,
listStyle: "none",
background: "white",
border: "1px solid #cbd5e1",
borderRadius: 8,
boxShadow: "0 4px 12px rgba(0,0,0,0.08)",
minWidth: 180,
}}
>
{COUNTRIES.map((c) => (
<li
key={c}
onClick={() => {
onChange(c);
setOpen(false);
}}
style={{
padding: "6px 10px",
borderRadius: 4,
cursor: "pointer",
background: c === value ? "#eff6ff" : "transparent",
}}
>
{c}
</li>
))}
</ul>
)}
</div>
);
}
export default function SettingsForm() {
const [settings, setSettings] = useLocalStorage<Settings>("account-settings", {
username: "",
country: "",
notifications: true,
});
const debouncedUsername = useDebounce(settings.username, 400);
const [status, setStatus] = useState<"idle" | "checking" | "ok" | "taken">("idle");
useEffect(() => {
if (!debouncedUsername) {
setStatus("idle");
return;
}
let cancelled = false;
setStatus("checking");
fetch(`/api/username?u=${encodeURIComponent(debouncedUsername)}`)
.then((r) => r.json())
.then((data) => {
if (!cancelled) setStatus(data.available ? "ok" : "taken");
})
.catch(() => {
if (!cancelled) setStatus("idle");
});
return () => {
cancelled = true;
};
}, [debouncedUsername]);
return (
<form
style={{
maxWidth: 480,
display: "grid",
gap: 16,
fontFamily: "system-ui, sans-serif",
}}
onSubmit={(e) => e.preventDefault()}
>
<label style={{ display: "grid", gap: 4 }}>
<span style={{ fontSize: 14, color: "#475569" }}>Username</span>
<input
value={settings.username}
onChange={(e) =>
setSettings({ ...settings, username: e.target.value })
}
style={{
padding: "8px 10px",
borderRadius: 6,
border: "1px solid #cbd5e1",
}}
/>
<span style={{ fontSize: 12, color: "#64748b" }}>
{status === "checking" && "Checking..."}
{status === "ok" && "✓ Available"}
{status === "taken" && "✗ Taken"}
</span>
</label>
<label style={{ display: "grid", gap: 4 }}>
<span style={{ fontSize: 14, color: "#475569" }}>Country</span>
<CountryPicker
value={settings.country}
onChange={(country) => setSettings({ ...settings, country })}
/>
</label>
<label
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<span style={{ fontSize: 14, color: "#475569" }}>
Email notifications
</span>
<NotificationSwitch
value={settings.notifications}
onChange={(notifications) =>
setSettings({ ...settings, notifications })
}
/>
</label>
</form>
);
}
Four hooks, four responsibilities, zero overlap:
-
useDebounceturns rapid keystrokes into one delayed value, so async validation only fires after the user pauses -
useControlledlets the switch component accept either avalueprop or adefaultValue, without duplicating the branch logic -
useLocalStoragepersists the entire settings object across refreshes, with SSR-safe init and cross-tab sync -
useClickOutsidecloses the country menu when the user clicks anywhere outside the trigger or the menu — including portaled rendering targets
The form file ends up roughly 200 lines of mostly markup. The fiddly browser plumbing — timer cleanup, SSR storage access, controlled-vs-uncontrolled detection, document-level listeners — lives inside library hooks that have already been tested across the patterns where teams usually get them wrong.
Installation
npm i @reactuses/core
Related Hooks
-
useDebounce— Lag a value behind its input by a fixed delay -
useDebounceFn— Debounce a callback rather than a value -
useControlled— Build components that accept either controlled or uncontrolled props -
useLocalStorage—useStatethat persists to localStorage with SSR safety and cross-tab sync -
useSessionStorage— Same shape asuseLocalStorage, scoped to the session -
useClickOutside— Detect clicks outside one or more elements -
useClickAway— Single-ref variant of click-outside detection -
useToggle— Boolean state with an explicit toggle setter -
usePrevious— Read the previous value of a piece of state, useful for change detection in forms
ReactUse provides 100+ hooks for React. Explore them all →
Top comments (0)