useToggle 自定义 函数签名 const [on, toggle, setOn] = useToggle(initial = false)复制
布尔 state 的三元组:当前值、引用稳定的 toggle 函数、原始 setter。toggle 忽略参数,可以直接当 onClick 传。
✓ 何时用
折叠面板的展开。模态框开关。亮 / 暗主题切换。任何地方写过 `setOpen(o => !o)` 超过一次。
✗ 何时不用
非布尔的 state。需要乐观或异步切换的布尔,用 useOptimistic 或 useActionState。
⚠ 常见坑: toggle 直接传给 onClick 时,TypeScript 可能抱怨 MouseEvent vs void,把签名重载成忽略参数的 () => void。
例子 function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = useCallback(() => setOn(o => !o), []);
return [on, toggle, setOn] as const;
}
const [open, toggleOpen] = useToggle();
<button onClick={toggleOpen}>{open ? 'Hide' : 'Show'}</button>
useLocalStorage 自定义 函数签名 const [value, setValue] = useLocalStorage<T>(key, initial)复制
签名和 useState 一样,但会写到 window.localStorage,挂载时回读。要走 JSON 序列化、要防 SSR(window 是 undefined)。
✓ 何时用
用户偏好(主题、侧边栏折叠状态)。表单草稿。任何"刷新后还得在",但不该放到服务端的值。
✗ 何时不用
敏感数据(DevTools 一打开谁都能看)。大块数据(每个站点上限 5 MB,而且是同步 I/O)。跨标签同步如果没监听 `storage` 事件,做不到。
⚠ 常见坑: JSON.parse 解析出错会抛,包 try/catch,失败回到 initial。Next.js / Astro 这类 SSR 框架里必须判 typeof window === "undefined",否则 build 直接挂。
例子 function useLocalStorage<T>(key: string, initial: T) {
const [v, setV] = useState<T>(() => {
if (typeof window === 'undefined') return initial;
try { const raw = localStorage.getItem(key); return raw ? JSON.parse(raw) : initial; }
catch { return initial; }
});
useEffect(() => { localStorage.setItem(key, JSON.stringify(v)); }, [key, v]);
return [v, setV] as const;
}
useDebounce 自定义 函数签名 const debounced = useDebounce<T>(value, delayMs)复制
返回一个"静默 delayMs 毫秒后才更新"的值,每次变化都重置计时器。用来限流由频繁变化的值触发的昂贵动作。
✓ 何时用
边输入边搜,但等用户停一下再发 API。表单自动保存草稿。窗口 resize → 重算布局。
✗ 何时不用
每一次更新都重要的(受控输入的 value 本身不能 debounce,只 debounce 派生的副作用)。在并发渲染下 useDeferredValue 能达到同样体验的场景。
⚠ 常见坑: 依赖变化时返回旧值,清理函数一定要清掉计时器。delay 太短等于没做;太长用户以为坏了。搜索场景 200-500 ms 是甜区。
例子 function useDebounce<T>(value: T, delay = 300): T {
const [v, setV] = useState(value);
useEffect(() => {
const id = setTimeout(() => setV(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return v;
}
useFetch 自定义 函数签名 const { data, error, loading } = useFetch<T>(url, options?)复制
一次性 GET 请求的小工具 hook。返回 { data, error, loading },url 变化或卸载时取消请求。 不是 React Query / SWR 的替代品,那些做了缓存、重试、去重。
✓ 何时用
原型、demo、内部工具,上 React Query 没必要的小场景。读单个不需要缓存的接口。
✗ 何时不用
生产应用。被多处读的接口(你最后会手写一份去重)。任何需要缓存、重试、变更的地方。
⚠ 常见坑: 不写 AbortController,url 变化频繁时会出现 旧 响应覆盖 新 响应的竞态。清理函数里必须 abort。
例子 function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const ctrl = new AbortController();
setLoading(true);
fetch(url, { signal: ctrl.signal })
.then(r => r.json()).then(d => { setData(d); setLoading(false); })
.catch(e => { if (e.name !== 'AbortError') { setError(e); setLoading(false); } });
return () => ctrl.abort();
}, [url]);
return { data, error, loading };
}
useMediaQuery 自定义 函数签名 const matches = useMediaQuery(query: string): boolean复制
返回某条 CSS media query 当前是否匹配。通过 window.matchMedia 订阅,变化时重渲染。和 useSyncExternalStore 配合可以并发安全。
✓ 何时用
移动 / 桌面渲染不同组件。在断点切换布局。通过 (prefers-color-scheme: dark) 检测系统深色模式。
✗ 何时不用
纯样式:CSS media query 比 React 重渲染便宜。要测尺寸再决定布局的(用 ResizeObserver)。
⚠ 常见坑: SSR 下 window 是 undefined 所以返回 false,会引发布局闪一下。用 useSyncExternalStore 包一层并给合理的服务端快照,或者只在客户端渲染。
例子 function useMediaQuery(query: string) {
const subscribe = (cb: () => void) => {
const mql = window.matchMedia(query);
mql.addEventListener('change', cb);
return () => mql.removeEventListener('change', cb);
};
return useSyncExternalStore(
subscribe,
() => window.matchMedia(query).matches,
() => false,
);
}
const isMobile = useMediaQuery('(max-width: 768px)');
useEventListener 自定义 函数签名 useEventListener<K>(event: K, handler: (e) => void, target?, options?)复制
对 addEventListener 的封装,把监听绑到 React 生命周期上,并用 ref 保持 handler 引用最新,而不必每次渲染都重新订阅。
✓ 何时用
window 上的快捷键。scroll / resize / online-offline。监听第三方组件的 ref。任何要手写 useEffect + addEventListener + cleanup 的场景。
✗ 何时不用
React 自己管理的元素,直接用 onClick / onKeyDown props。高频事件(document 上的 mousemove)不做节流。
⚠ 常见坑: 不用 handler-in-ref 这个技巧,每次渲染都重新订阅,监听器越攒越多。要显式列依赖,handler 走 ref 保鲜。
例子 function useEventListener<K extends keyof WindowEventMap>(
event: K,
handler: (e: WindowEventMap[K]) => void,
) {
const ref = useRef(handler);
useEffect(() => { ref.current = handler; }, [handler]);
useEffect(() => {
const fn = (e: WindowEventMap[K]) => ref.current(e);
window.addEventListener(event, fn);
return () => window.removeEventListener(event, fn);
}, [event]);
}
useOnClickOutside 自定义 函数签名 useOnClickOutside(ref: RefObject<HTMLElement>, handler: (e) => void)复制
在引用元素 之外 发生 mousedown / touchstart 时调用 handler。关 popover / 下拉 / 模态框的标准写法。
✓ 何时用
自定义下拉、popover、提及选择器、tooltip 的"点外面关闭"。
✗ 何时不用
原生 <dialog>(浏览器自带 Escape 和外部点击)。Portal 内的组件,ref.contains 检查会假阴性,因为 portal 子节点不在 ref 子树。
⚠ 常见坑: click 事件触发太晚(目标可能已经被移除)。用 mousedown / pointerdown / touchstart,确保下一个 click handler 跑之前关闭已经发生。
例子 function useOnClickOutside(
ref: RefObject<HTMLElement>,
handler: (e: MouseEvent | TouchEvent) => void,
) {
useEffect(() => {
const fn = (e: MouseEvent | TouchEvent) => {
const el = ref.current;
if (!el || el.contains(e.target as Node)) return;
handler(e);
};
document.addEventListener('mousedown', fn);
document.addEventListener('touchstart', fn);
return () => {
document.removeEventListener('mousedown', fn);
document.removeEventListener('touchstart', fn);
};
}, [ref, handler]);
}
usePrevious 自定义 函数签名 const previous = usePrevious<T>(value): T | undefined复制
返回 上一次 渲染时的值。实现是在 useEffect 里往 ref 写当前值,渲染期读到的就是"上一次"的。
✓ 何时用
在新旧值之间做过渡动画。检测"上一次是开,这次是关"这种状态变化。和上次 prop 对比但不想冗余存一份 state。
✗ 何时不用
真要 渲染 新旧两个值的(用 useState 或 useReducer)。要派生数据的上一个的(直接派生两份对比即可)。
⚠ 常见坑: 因为 ref 是在渲染 之后 的 useEffect 里写的,首次渲染返回 undefined。代码里必须显式处理 undefined。
例子 function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T | undefined>(undefined);
useEffect(() => { ref.current = value; }, [value]);
return ref.current;
}
const prevCount = usePrevious(count);
const direction = prevCount === undefined ? 'init' : count > prevCount ? 'up' : 'down';
useWindowSize 自定义 函数签名 const { width, height } = useWindowSize(): { width: number; height: number }复制
返回实时的窗口尺寸。订阅 resize 事件,变化时更新。SSR 安全的版本用 useSyncExternalStore 配服务端快照。
✓ 何时用
虚拟列表要 viewport 高度。Canvas / SVG 要按窗口尺寸绘制。CSS 表达不了的"超过断点才渲染某组件"的判断。
✗ 何时不用
纯 CSS 响应式:media query 更便宜。元素级别的尺寸(用 ResizeObserver,不是 window resize)。
⚠ 常见坑: resize 在某些平台 60+ Hz。不做节流,拖动每一像素都会让整个子树重渲染。节流到 16-100 ms,或用 rAF 批处理。
例子 function useWindowSize() {
const [size, setSize] = useState(() => ({
width: typeof window === 'undefined' ? 0 : window.innerWidth,
height: typeof window === 'undefined' ? 0 : window.innerHeight,
}));
useEffect(() => {
let raf = 0;
const onResize = () => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
setSize({ width: window.innerWidth, height: window.innerHeight });
});
};
window.addEventListener('resize', onResize);
return () => { cancelAnimationFrame(raf); window.removeEventListener('resize', onResize); };
}, []);
return size;
}
useInterval 自定义 函数签名 useInterval(callback: () => void, delayMs: number | null)复制
声明式的 setInterval。delay 传 null 暂停。用 ref 始终调最新的 callback 闭包,而不必每次渲染都重建 interval。
✓ 何时用
轮询状态。时钟滴答。动画帧的兜底。任何"通过 state 干净暂停"的重复动作。
✗ 何时不用
丝滑动画(用 requestAnimationFrame 配合 vsync)。一次性定时(就 useEffect + setTimeout)。亚毫秒精度(浏览器最小 4 ms,会被节流)。
⚠ 常见坑: 简单写 `useEffect(() => { setInterval(cb, d); }, [cb])` 每次 cb 引用变就重建 interval,通常每次渲染都变。Dan Abramov 博文里推广的 ref-the-callback 写法才是正解。
例子 function useInterval(cb: () => void, delay: number | null) {
const ref = useRef(cb);
useEffect(() => { ref.current = cb; }, [cb]);
useEffect(() => {
if (delay === null) return;
const id = setInterval(() => ref.current(), delay);
return () => clearInterval(id);
}, [delay]);
}
useInterval(() => setNow(Date.now()), running ? 1000 : null);
useTimeout 自定义 函数签名 useTimeout(callback: () => void, delayMs: number | null)复制
声明式的 setTimeout。delayMs 毫秒后触发一次回调;传 null 取消。和 useInterval 一样,用 ref 保持最新回调,改回调不会重置定时器。
✓ 何时用
几秒后自动关掉 toast。延迟一会儿再显示加载圈。触发一次性动作,prop 关掉时还能取消。
✗ 何时不用
重复动作(用 useInterval)。动画计时(用 requestAnimationFrame)。一处的 useEffect + setTimeout 已经够清楚的场景。
⚠ 常见坑: 和 useInterval 一样,把回调放进依赖会让定时器每次渲染重置,结果永远不触发。回调走 ref,依赖只放 delay。
例子 function useTimeout(cb: () => void, delay: number | null) {
const ref = useRef(cb);
useEffect(() => { ref.current = cb; }, [cb]);
useEffect(() => {
if (delay === null) return;
const id = setTimeout(() => ref.current(), delay);
return () => clearTimeout(id);
}, [delay]);
}
useTimeout(() => setVisible(false), visible ? 4000 : null);
useIsMounted 自定义 函数签名 const isMounted = useIsMounted(): () => boolean复制
返回一个 getter,告诉你组件是否还挂载着。在异步任务末尾 setState 之前判一下,避免老版本的"卸载后更新组件"警告。
✓ 何时用
在不方便改成 AbortController 的 await 之后,给 setState 加一道保险。卸载后才回调的第三方接口。
✗ 何时不用
fetch 流程,优先 AbortController,它能直接取消请求。当默认套路用:React 18+ 已经移除卸载警告,大多数代码不需要它。
⚠ 常见坑: 返回布尔快照而不是 getter 是错的,值在渲染时就被定住、会失效。要返回一个调用时才读 ref 的函数。
例子 function useIsMounted() {
const mounted = useRef(false);
useEffect(() => {
mounted.current = true;
return () => { mounted.current = false; };
}, []);
return useCallback(() => mounted.current, []);
}
const isMounted = useIsMounted();
await save(); if (isMounted()) setDone(true);
useUpdateEffect 自定义 函数签名 useUpdateEffect(effect: () => (void | (() => void)), deps?: any[])复制
和 useEffect 一样,但跳过第一次(挂载那次)。只在之后的依赖更新时跑。用一个在首渲染后翻转的 ref 标志实现。
✓ 何时用
只在某个值"发生变化"时响应,不在挂载时触发,比如用户改了筛选后再同步 query 参数,或者只在更新时做动画。
✗ 何时不用
其实也想要挂载那次的(用普通 useEffect)。"跳过首次"是在掩盖设计问题时,有时 effect 本就不该依赖那个值。
⚠ 常见坑: React 18 StrictMode 开发模式下挂载会跑两次 effect,简单的布尔标志可能漏判。用初始为 false 的 ref,在 effect 末尾置 true,不要用模块级标志。
例子 function useUpdateEffect(effect: () => void | (() => void), deps?: any[]) {
const isFirst = useRef(true);
useEffect(() => {
if (isFirst.current) { isFirst.current = false; return; }
return effect();
}, deps);
}
useUpdateEffect(() => { syncUrl(filter); }, [filter]);
useSet 自定义 函数签名 const [set, { add, remove, toggle, has, reset }] = useSet<T>(initial?)复制
用 state 管理一个 Set,配一组引用稳定的辅助方法。每个方法返回 新 Set(保持不可变),React 才能检测到变化。常用于多选 / 复选框列表。
✓ 何时用
表格里选中的行 id。展开的树节点 id。任何频繁问"这个 id 选中了吗"的"成员关系"状态。
✗ 何时不用
单个布尔(useState)。在意插入顺序或允许重复的有序数据(用数组)。每帧更新的大 Set(每次重建 Set 是 O(n))。
⚠ 常见坑: 原地改 Set(set.add(x); setSet(set))不会重渲染,引用没变。一定要建新 Set:setSet(s => new Set(s).add(x))。
例子 function useSet<T>(initial?: Iterable<T>) {
const [set, setSet] = useState(() => new Set(initial));
const actions = useMemo(() => ({
add: (v: T) => setSet(s => new Set(s).add(v)),
remove: (v: T) => setSet(s => { const n = new Set(s); n.delete(v); return n; }),
toggle: (v: T) => setSet(s => { const n = new Set(s); n.has(v) ? n.delete(v) : n.add(v); return n; }),
reset: () => setSet(new Set()),
}), []);
return [set, actions] as const;
}
useCounter 自定义 函数签名 const [count, { inc, dec, set, reset }] = useCounter(initial = 0, opts?)复制
数字 state,配引用稳定的 inc/dec/set/reset,可选 min/max 钳制。所有更新都用函数式 setState,连点也不会丢更新。
✓ 何时用
数量步进器、分页页码、评分控件,任何"有上下界、操作固定"的整数 state。
✗ 何时不用
自由输入的数字(直接 useState + 改变时 parse)。非整数或带单位、钳制逻辑更复杂的值。
⚠ 常见坑: 钳制要放在 updater 里面,不能放外面:inc 应是 setCount(c => clamp(c + 1))。对捕获的 count 钳制会丢并发更新。
例子 function useCounter(initial = 0, { min = -Infinity, max = Infinity } = {}) {
const clamp = (n: number) => Math.min(max, Math.max(min, n));
const [count, setCount] = useState(clamp(initial));
const actions = useMemo(() => ({
inc: (step = 1) => setCount(c => clamp(c + step)),
dec: (step = 1) => setCount(c => clamp(c - step)),
set: (n: number) => setCount(clamp(n)),
reset: () => setCount(clamp(initial)),
}), []);
return [count, actions] as const;
}
useClipboard 自定义 函数签名 const { copy, copied } = useClipboard(resetMs = 1500)复制
把文本复制到剪贴板,并暴露一个短暂为 true 的 `copied` 标志。封装异步的 navigator.clipboard.writeText,用计时器重置标志。
✓ 何时用
代码片段、分享链接、API key 的复制按钮。任何想要"已复制!"提示并自动消失的地方。
✗ 何时不用
复制富文本 / 图片(剪贴板 API 要 ClipboardItem,不是 writeText)。非安全上下文,http:// 和老浏览器下 navigator.clipboard 是 undefined。
⚠ 常见坑: navigator.clipboard.writeText 返回的 Promise 在没有用户手势或权限被拒时会 reject。一定要 .catch,调用前先做特性检测。
例子 function useClipboard(resetMs = 1500) {
const [copied, setCopied] = useState(false);
const copy = useCallback(async (text: string) => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), resetMs);
} catch { setCopied(false); }
}, [resetMs]);
return { copy, copied };
}
useHover 自定义 函数签名 const [ref, hovered] = useHover<T extends HTMLElement>()复制
检测指针是否悬在某元素上。返回一个要绑的 ref 和一个布尔值。用 mouseenter / mouseleave,不会像 mouseover 那样因子元素冒泡而误触发。
✓ 何时用
行悬停时显示工具条或删除按钮。悬停时懒加载预览。CSS :hover 够不到 React state 的场景。
✗ 何时不用
纯视觉的悬停效果(CSS :hover 便宜得多)。纯触屏 UI,触屏没有悬停,要设计点按的替代方案。
⚠ 常见坑: ref 指向的节点变化时要重新绑监听,得用 ref 回调,不是静态 ref 对象。用 mouseover 而不是 mouseenter,指针掠过子元素时 hovered 会抖。
例子 function useHover<T extends HTMLElement>() {
const [hovered, setHovered] = useState(false);
const ref = useRef<T | null>(null);
useEffect(() => {
const node = ref.current;
if (!node) return;
const on = () => setHovered(true);
const off = () => setHovered(false);
node.addEventListener('mouseenter', on);
node.addEventListener('mouseleave', off);
return () => {
node.removeEventListener('mouseenter', on);
node.removeEventListener('mouseleave', off);
};
}, []);
return [ref, hovered] as const;
}
useIntersectionObserver 自定义 函数签名 const [ref, entry] = useIntersectionObserver(options?: IntersectionObserverInit)复制
用 IntersectionObserver 观察元素进入或离开视口。返回一个 ref 和最新的 IntersectionObserverEntry(entry.isIntersecting 表示是否可见)。
✓ 何时用
滚动时懒加载图片 / 组件。无限滚动的哨兵节点。区块滚入视口时只触发一次的动画。曝光埋点。
✗ 何时不用
像素级精确的滚动计算(用滚动位置)。要测元素尺寸而不是可见性的(用 ResizeObserver)。没 polyfill 的老浏览器。
⚠ 常见坑: 每次渲染都重建 observer 会拖垮性能,依赖只放序列化后的 options,清理时 disconnect。observer 是异步的:首个 entry 可能在挂载后一帧才到。
例子 function useIntersectionObserver(options?: IntersectionObserverInit) {
const [entry, setEntry] = useState<IntersectionObserverEntry | null>(null);
const ref = useRef<HTMLElement | null>(null);
useEffect(() => {
const node = ref.current;
if (!node) return;
const io = new IntersectionObserver(([e]) => setEntry(e), options);
io.observe(node);
return () => io.disconnect();
}, [options?.root, options?.rootMargin, options?.threshold]);
return [ref, entry] as const;
}
useDocumentTitle 自定义 函数签名 useDocumentTitle(title: string, restoreOnUnmount = false)复制
把 document.title 设为给定字符串并保持同步。可选在卸载时恢复之前的标题。一个小巧的、用 effect 同步到非 React 全局量的封装。
✓ 何时用
在客户端渲染的 SPA 里,把当前路由 / 条目名反映到标签页标题。显示未读数,比如"(3) 收件箱"。
✗ 何时不用
SSR / 元框架,用框架的 head API(Next metadata、Astro <title>),爬虫和社交预览才看得到。这里设的标题只在客户端生效。
⚠ 常见坑: 在渲染期(而非 effect 里)设 document.title 是渲染期副作用,会破坏并发渲染。一定要放在 useEffect 里。
例子 function useDocumentTitle(title: string, restore = false) {
const prev = useRef(typeof document !== 'undefined' ? document.title : '');
useEffect(() => {
document.title = title;
}, [title]);
useEffect(() => {
const previous = prev.current;
return () => { if (restore) document.title = previous; };
}, [restore]);
}
useEventCallback 自定义 函数签名 const stable = useEventCallback<A extends any[], R>(fn: (...a: A) => R)复制
返回一个引用稳定、但内部总是调最新 fn 的函数。是提案中 useEffectEvent 的用户态实现,让你在回调里读最新 props/state 而不必加依赖。
✓ 何时用
传给很多 memo 子组件、既要引用稳定又要读最新 state 的 handler。避免"函数每次渲染都变"导致的依赖抖动。
✗ 何时不用
不要在渲染期调用返回的函数,它故意读最新闭包,只在 effect / 事件 handler 里安全。React 自己要 diff 的值,优先用真实依赖。
⚠ 常见坑: 渲染期调用会拿到上次提交的值,并发渲染下可能撕裂。React 为此推出了 useEffectEvent,用户态版本只是过渡方案。
例子 function useEventCallback<A extends any[], R>(fn: (...a: A) => R) {
const ref = useRef(fn);
useLayoutEffect(() => { ref.current = fn; });
return useCallback((...args: A) => ref.current(...args), []);
}
const onScroll = useEventCallback(() => log(pageName, window.scrollY));
useThrottle 自定义 函数签名 const throttled = useThrottle<T>(value, intervalMs)复制
返回一个最多每 intervalMs 更新一次的值,哪怕输入变得更快。和 debounce(等静默)不同,throttle 保证稳定的更新节奏。
✓ 何时用
由滚动 / mousemove 驱动、必须在手势进行中更新而非停止后才更新的 UI。要按固定频率跳动的实时进度。
✗ 何时不用
边输入边搜,只在意停顿后的最终值(用 debounce)。useDeferredValue 已经能平滑重渲染的场景。
⚠ 常见坑: 只取首次的 throttle,如果最后一个值落在窗口内会被丢,要安排一次尾随更新,别丢最后的值。清理时清掉计时器。
例子 function useThrottle<T>(value: T, interval = 200): T {
const [throttled, setThrottled] = useState(value);
const last = useRef(Date.now());
useEffect(() => {
const now = Date.now();
const remaining = interval - (now - last.current);
if (remaining <= 0) { last.current = now; setThrottled(value); return; }
const id = setTimeout(() => { last.current = Date.now(); setThrottled(value); }, remaining);
return () => clearTimeout(id);
}, [value, interval]);
return throttled;
}
useScrollLock 自定义 函数签名 useScrollLock(locked: boolean)复制
锁定或解锁页面滚动。锁定时给 documentElement 设 overflow:hidden,解锁 / 卸载时恢复之前的值。模态框和抽屉的标准做法。
✓ 何时用
打开模态框 / 抽屉 / 全屏菜单时冻结背景滚动。图片灯箱。
✗ 何时不用
用 showModal() 的原生 <dialog>(浏览器已自动阻止背景滚动)。多个锁可能叠加时,要用计数器而不是布尔,否则解锁一个会过早恢复滚动。
⚠ 常见坑: iOS Safari 上单靠 overflow:hidden 不一定能挡住橡皮筋滚动,可能还要固定 body 定位并恢复 scrollTop。要保存并恢复原始 overflow 值,别假设它是 "auto"。
例子 function useScrollLock(locked: boolean) {
useLayoutEffect(() => {
if (!locked) return;
const root = document.documentElement;
const prev = root.style.overflow;
root.style.overflow = 'hidden';
return () => { root.style.overflow = prev; };
}, [locked]);
}
useControllableState 自定义 函数签名 const [state, setState] = useControllableState<T>({ value, defaultValue, onChange })复制
让同一个组件 同时 支持受控和非受控用法。传了 props.value 就用它(受控),否则用内部 state(非受控),两种情况都会调 onChange。
✓ 何时用
设计系统里可复用的 input / 开关 / select,使用方可能想自己控制、也可能让组件自管,像 Radix 和 React Aria 那样。
✗ 何时不用
永远受控或永远非受控的组件,双模式逻辑纯属累赘。深层嵌套的表单 state 交给表单库更好。
⚠ 常见坑: 在渲染之间让组件在受控和非受控之间切换(value 从 undefined 变成有值)会报警告并重置,要在挂载时根据 value 初值是否定义一次性定下模式。
例子 function useControllableState<T>({ value, defaultValue, onChange }: {
value?: T; defaultValue?: T; onChange?: (v: T) => void;
}) {
const isControlled = value !== undefined;
const [uncontrolled, setUncontrolled] = useState(defaultValue);
const state = isControlled ? value : uncontrolled;
const setState = useCallback((next: T) => {
if (!isControlled) setUncontrolled(next);
onChange?.(next);
}, [isControlled, onChange]);
return [state, setState] as const;
}
useRenderCount 自定义 函数签名 const renders = useRenderCount(): number复制
返回组件渲染了多少次。一个调试用 hook,每次渲染就给 ref 加一。优化时用来抓出意料之外的重渲染。
✓ 何时用
性能调试时临时用,确认 memo 是否真的减少了重渲染。开发期打日志,找出渲染风暴。
✗ 何时不用
生产代码 / 业务逻辑,渲染次数是实现细节,StrictMode 开发下还会翻倍。绝不要拿它分支真实行为。
⚠ 常见坑: React 18 StrictMode 开发下组件挂载会渲染两次,所以计数偏高。这里渲染期读并改 ref 没问题,但别依赖它的精确值。
例子 function useRenderCount() {
const count = useRef(0);
count.current += 1;
return count.current;
}
const renders = useRenderCount();
console.log('Table rendered', renders, 'times');
useStateWithHistory 自定义 函数签名 const [state, set, { undo, redo, canUndo, canRedo }] = useStateWithHistory<T>(initial)复制
state 外加一个撤销 / 重做的历史栈。每次 set 压入历史;undo / redo 移动一个指针。指针模型让 redo 一直可用,直到新值分叉时间线。
✓ 何时用
编辑器、画布、表单构建器,任何期望 Ctrl+Z / Ctrl+Y 的地方。调试时单步回看 state。
✗ 何时不用
又大又频繁变化的 state,不设上限历史会无限占内存。高频更新(给压栈的内容设上限或 debounce)。
⚠ 常见坑: 指针停在历史中间时设新值,必须 截断 redo 尾部,否则 redo 会重放用户已经放弃的分支。给栈长度设上限,防止内存增长。
例子 function useStateWithHistory<T>(initial: T) {
const [history, setHistory] = useState<T[]>([initial]);
const [ptr, setPtr] = useState(0);
const set = useCallback((value: T) => {
setHistory(h => [...h.slice(0, ptr + 1), value]);
setPtr(p => p + 1);
}, [ptr]);
const undo = useCallback(() => setPtr(p => Math.max(0, p - 1)), []);
const redo = useCallback(() => setPtr(p => Math.min(history.length - 1, p + 1)), [history.length]);
return [history[ptr], set, {
undo, redo, canUndo: ptr > 0, canRedo: ptr < history.length - 1,
}] as const;
}
useKeyPress 自定义 函数签名 useKeyPress(targetKey: string, handler: (e: KeyboardEvent) => void)复制
在 window 上按下指定键时调用 handler。匹配 KeyboardEvent.key(如 "Escape"、"Enter"、"k")。用 ref 保持 handler,总能读到最新 state。
✓ 何时用
Escape 关模态框。Enter 提交。单键快捷键。配合修饰键判断做 Cmd+K 这类组合。
✗ 何时不用
在输入框里打字时,要挡住快捷键,别在聚焦字段时触发。复杂的组合键 / 序列(用专门的快捷键库)。
⚠ 常见坑: 用 event.key 匹配,不要用 event.keyCode(已废弃)。键值是 "Escape" 不是 "Esc"。不给 handler 套 ref,每次渲染都会重新订阅监听器。
例子 function useKeyPress(targetKey: string, handler: (e: KeyboardEvent) => void) {
const ref = useRef(handler);
useEffect(() => { ref.current = handler; }, [handler]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === targetKey) ref.current(e);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [targetKey]);
}
useKeyPress('Escape', () => setOpen(false));
useDebouncedCallback 自定义 函数签名 const debounced = useDebouncedCallback<A extends any[]>(fn: (...a: A) => void, delayMs)复制
debounce 一个 回调(不是值):返回一个稳定函数,把对 fn 的调用推迟到静默 delayMs 之后。每次调用重置计时器,最后一次的参数生效。卸载时取消。
✓ 何时用
在输入停止的尾沿发 API 调用,同时把事件 / 参数传过去。自动保存,用最新 payload 调 save(payload)。
✗ 何时不用
debounce 受控输入的 值(对值用 useDebounce,绝不要 debounce 那个 set 它的 onChange)。需要首沿触发的(这个是尾沿)。
⚠ 常见坑: 每次渲染返回新函数会让 debounce 失效,计时器要存在 ref 里、返回的函数必须稳定。清理时一定要清掉待执行的计时器,否则频繁卸载会泄漏计时器。
例子 function useDebouncedCallback<A extends any[]>(fn: (...a: A) => void, delay = 300) {
const fnRef = useRef(fn);
useEffect(() => { fnRef.current = fn; }, [fn]);
const timer = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => () => clearTimeout(timer.current), []);
return useCallback((...args: A) => {
clearTimeout(timer.current);
timer.current = setTimeout(() => fnRef.current(...args), delay);
}, [delay]);
}
const save = useDebouncedCallback((draft: Draft) => api.save(draft), 800);
useFavicon 自定义 函数签名 useFavicon(href: string)复制
运行时切换页面 favicon,更新(或创建)<link rel="icon"> 元素。一个小巧的、用 effect 同步到 document head 的封装,像 useDocumentTitle 但换的是图标。
✓ 何时用
显示带未读 / 告警角标的 favicon。区分环境(staging 用红色图标)。让图标跟随深 / 浅色模式。
✗ 何时不用
SSR / 静态 meta,在 HTML head 里设 favicon,水合前就加载好。这个只用于客户端运行时切换。
⚠ 常见坑: 每次调用都新建 <link> 会堆积重复的图标链接。先查已有的 rel="icon" 复用,没有时才创建一个。
例子 function useFavicon(href: string) {
useEffect(() => {
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
if (!link) {
link = document.createElement('link');
link.rel = 'icon';
document.head.appendChild(link);
}
link.href = href;
}, [href]);
}
useFavicon(unread > 0 ? '/favicon-alert.ico' : '/favicon.ico');