表单是每个 React 应用里被重写次数最多的部分。第一天看上去再简单不过------丢一个 <input>,把 onChange 接到 useState,发版。到了第三个月,同一个表单上多了异步用户名校验、一份自动保存的草稿、一个自定义日期浮层,以及一个必须和设计系统配合好的"受控/非受控"开关。每一项都拖进来自己的临时状态机、自己的 effect 清理逻辑,以及自己那一堆边界情况。表单文件成了仓库里最长的那一个,团队里没人愿意碰它。
本文将走过四个非平凡表单迟早都会用到的原语:用一个防抖值来限流异步校验、用一个"受控或非受控"包装让组件两种用法都接受、用 localStorage 撑起一份能在刷新中存活的草稿,以及一个不会泄漏监听器的"点击外部关闭"浮层方案。每一个原语,我们都会先写手动版本,把代价摆出来,再换成 ReactUse 中专门的 Hook。最后我们把四个 Hook 组合成一个完整的"账户设置"表单:边输入边校验、自动保存草稿、还包含一个国家选择浮层。
1. 防抖的异步校验
手动实现
异步校验最经典的错误,是每敲一个键就发一次请求。经典的修法是 setTimeout,经典的 bug 是忘了清理上一次的定时器:
tsx
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>
用户名
<input value={username} onChange={(e) => setUsername(e.target.value)} />
<span>{status}</span>
</label>
);
}
这里有两个 effect,干着两件不同的事,还必须保持同步。第一个是防抖器:把 username 的密集变化压成一个延迟后的 debounced 值。第二个是请求执行器:当 debounced 变化时发请求,并忽略掉过期返回。两个 effect 都需要自己的清理逻辑。忘了 clearTimeout,请求会重复;忘了 cancelled 标志,竞态会让旧响应覆盖新响应。
真正的代价不是行数------而是这段防抖逻辑被焊死在了这个具体字段上。要在 email 字段复用同样的能力,就得复制粘贴这五行。
ReactUse 的写法:useDebounce
useDebounce 返回一个比输入值落后固定延迟的值:
tsx
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>
用户名
<input value={username} onChange={(e) => setUsername(e.target.value)} />
<span>{status}</span>
</label>
);
}
第一个 effect------专管防抖的那个------消失了。useDebounce 自己接管了定时器和清理。剩下的代码才是真正属于你这个表单的部分:当防抖值变化时跑一次校验请求,并丢弃过期返回。
这个 Hook 还和函数版的 useDebounceFn 天然搭配------当你想给的是一个事件处理器(比如"失焦保存")而不是一个值时,就用它。
2. 受控还是非受控------选一种,两种都支持
手动实现
库组件经常面对一个老问题:消费者应当传 value 和 onChange,还是让组件内部用 defaultValue 自己管状态?老实说答案是"看谁用"。大多数团队都得在每个字段上重新发明一遍这个模式:
tsx
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 ? "开" : "关"}
</button>
);
}
模式本身不复杂,但它是一块吸 bug 的磁铁。如果消费者中途把 value 切回 undefined,模式就在受控和非受控间跳了一次。如果他们传了 value 却没传 onChange 呢?React 自己的表单输入会对这两种情况都给出警告,但自定义组件几乎从不写这些校验------而当设计系统不断扩张,每一个 input、switch、slider、date picker 都会复制一遍这堆样板。
ReactUse 的写法:useControlled
useControlled 把整个模式塌缩成一个 Hook 调用:
tsx
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 ? "开" : "关"}
</button>
);
}
这个 Hook 替你做了三件你本来要自己写的事:
- 首次渲染时定型------决定是受控还是非受控,如果之后模式翻转就给出警告,和 React 内置 input 的诊断口径一致。
- 返回一个稳定的 setter ,内部根据模式分支:非受控时更新内部状态;受控时只调
onChange,让父组件去重新渲染。 - 始终反映最新的事实 。元组的第一个元素在受控时是
value、非受控时是内部状态,消费者永远不会看到不一致。
把它丢进设计系统里任何 input 形状的组件,从此不再为这个模式分心。
3. 自动保存表单草稿
手动实现
长表单------引导流、设置页、内容编辑器------绝不该让用户的工作毁于一次刷新。标准做法是把表单状态镜像到 localStorage;标准的失误是每敲一下键就写一次:
tsx
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>
);
}
这十五行里藏着三个问题。第一,惰性初始化会在挂载时读一次 localStorage,但不会在另一个标签页更新它时再读------多标签页编辑会安静地翻车。第二,JSON.parse 遇到损坏数据会抛错,组件就在挂载时崩了。第三,localStorage.setItem 是同步的,每次渲染都跑一次,对一个手快的用户而言会顶住主线程。
最上面那行 SSR 检查就是个信号:这是一段会被仓库里其它组件复制过去、并大概率写错的"配方"。
ReactUse 的写法:useLocalStorage
useLocalStorage 长得像 useState、用起来也像 useState,但值住在存储里:
tsx
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>
);
}
手动版本搞错或漏掉的四件事,这个 Hook 都帮你做好了:
- SSR 安全初始化。在服务端返回默认值;客户端首次渲染时无失配地完成水合。
- 跨标签页同步 。监听
storage事件,当另一个标签页写入同一个键时同步状态。 - JSON 容错。捕获解析错误并退回默认值,不再让组件崩溃。
- 稳定的 setter 。返回的 setter 引用稳定,可以安全地放进
useEffect依赖或 memo 化的子组件里。
对真的很长的表单,常常想要"自动保存 + 防抖"。把第一节的 useDebounce 搭进来------先防抖表单状态,再把防抖后的值写进存储------你就得到一个能在刷新中存活、又不会捶硬盘的编辑器。
4. 用"点击外部"关闭浮层
手动实现
国家选择器、日期选择器、自动补全菜单,以及一切浮在页面上的东西,都得在用户点别的地方时关掉自己。教科书式的实现是在 document 上监听:
tsx
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)}>切换</button>
{open && <div className="popover">{children}</div>}
</div>
);
}
简单场景这能跑------直到你的浮层被 portal 渲染到别处。ref.current.contains(...) 假设浮层是触发器的 DOM 后代,但真实的设计系统里几乎从来不是:浮层会被挂到 body 根节点,绕开父容器的 overflow。你还得在 mousedown 与 click 之间做选择(多数情况下答案是 mousedown,这样浮层会在某个下游 click 处理器触发之前就关掉),而且记得在关闭时跳过监听,免得每次页面 click 都白跑一遍。
ReactUse 的写法:useClickOutside
useClickOutside 接收一个 ref(或一组 ref)和一个处理器:
tsx
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)}>切换</button>
</div>
{open && (
<div ref={popoverRef} className="popover">
{children}
</div>
)}
</>
);
}
支持 ref 数组的形式,正是它能搞定 portal 浮层的关键:把触发器和浮动面板都标成"内部",点其它地方就触发处理器。Hook 也替你处理 mousedown 的选择,监听器只在 document 层挂一次(不会在每个组件里来回挂卸),并在卸载时清理干净。
它还有一个相近的兄弟 useClickAway------API 略有不同,适合只有单个 ref 的场景,按你组件里读起来更顺的那个挑就行。
组合在一起:账户设置表单
下面是一个完整的账户设置表单,把四个 Hook 都用上了。用户名边输入边校验。整个表单自动保存到 localStorage。通知开关是受控/非受控两可的组件。国家选择器是个对 portal 友好、点击外部就关的浮层。
tsx
import { useEffect, useRef, useState } from "react";
import {
useDebounce,
useControlled,
useLocalStorage,
useClickOutside,
} from "@reactuses/core";
interface Settings {
username: string;
country: string;
notifications: boolean;
}
const COUNTRIES = ["中国", "日本", "德国", "巴西", "印度"];
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 || "选择国家"} ▾
</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" }}>用户名</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" && "校验中..."}
{status === "ok" && "✓ 可用"}
{status === "taken" && "✗ 已被占用"}
</span>
</label>
<label style={{ display: "grid", gap: 4 }}>
<span style={{ fontSize: 14, color: "#475569" }}>国家</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" }}>邮件通知</span>
<NotificationSwitch
value={settings.notifications}
onChange={(notifications) =>
setSettings({ ...settings, notifications })
}
/>
</label>
</form>
);
}
四个 Hook,四种职责,零重叠:
useDebounce把密集敲击压成一次延迟值,让异步校验只在用户停顿后才发请求useControlled让开关组件同时接受value与defaultValue两种用法,不必复制分支逻辑useLocalStorage把整个设置对象在刷新中持久化,附带 SSR 安全初始化与跨标签页同步useClickOutside在用户点击触发器与菜单之外的任何地方时关闭国家菜单------portal 渲染同样工作
整个表单文件最后大约 200 行,绝大部分是 JSX 与样式。那些容易写错的浏览器细枝末节------定时器清理、SSR 存储访问、受控/非受控判别、document 级监听------都被收进了那些已经被各种翻车场景打磨过的库 Hook 里。
安装
bash
npm i @reactuses/core
相关 Hook
useDebounce--- 让一个值按固定延迟落后于其输入useDebounceFn--- 防抖一个回调而非一个值useControlled--- 构建同时接受受控/非受控用法的组件useLocalStorage--- 持久化到 localStorage 的useState,自带 SSR 安全与跨标签页同步useSessionStorage--- 与useLocalStorage同形,但作用域为会话useClickOutside--- 检测一个或多个元素之外的点击useClickAway--- 单 ref 版本的点击外部检测useToggle--- 带显式 toggle setter 的布尔状态usePrevious--- 读取上一次的状态值,用于表单中的变更检测
ReactUse 提供 100+ 个 React Hook。全部探索 →