自定义 Hook 是一个名字以 use 开头、并调用其他 Hook 的函数。当两个组件需要同样的有状态逻辑时,就可以写一个:把 useState 和 useEffect 调用移进这个函数,返回组件需要的东西,再在每个组件里调用它。
import { useState } from 'react';
function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = () => setOn((v) => !v);
return [on, toggle];
}
function Spoiler({ title, children }) {
const [open, toggle] = useToggle();
return (
<div style={{ marginBottom: 8 }}>
<button onClick={toggle}>{open ? 'Hide' : 'Show'} {title}</button>
{open && <p>{children}</p>}
</div>
);
}
export default function App() {
return (
<div>
<Spoiler title="ending">The butler did it.</Spoiler>
<Spoiler title="sequel">The butler did it again.</Spoiler>
</div>
);
}
两个剧透框都调用了 useToggle,打开其中一个,另一个仍然是关闭的。每次调用都会执行自己的 useState,所以每个组件都有自己的值。自定义 Hook 共享的是逻辑,不是状态。
编写规则
- 名字以
use 加一个大写字母开头:useToggle、useOnlineStatus。Hook 的 lint 规则靠这个前缀找到 Hook 调用并进行检查。
- 至少调用一个 Hook。 不调用任何 Hook 的辅助函数只是普通函数;把它命名为
formatDate 而不是 useFormatDate,这样它在任何地方都能调用,包括条件语句里。
- 遵守 Hook 的规则:在它的顶层调用 Hook,并且只在组件或其他自定义 Hook 中调用它。
- 返回有用的东西。 数组(
[on, toggle])方便调用方给值重命名;三个及以上的值用对象({ data, loading, error })读起来更清楚。
useLocalStorage:刷新后依然保留的状态
useState 在刷新后会忘掉一切。这个 Hook 从 localStorage 读取初始值,并把每次改动写回去。
import { useState, useEffect } from 'react';
function readStored(key, fallback) {
try {
const raw = window.localStorage.getItem(key);
return raw === null ? fallback : JSON.parse(raw);
} catch {
console.warn('localStorage is not available, keeping the value in memory');
return fallback;
}
}
function useLocalStorage(key, initial) {
const [value, setValue] = useState(() => readStored(key, initial));
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch {
// Storage blocked or full: the value still works for this visit
}
}, [key, value]);
return [value, setValue];
}
export default function App() {
const [name, setName] = useLocalStorage('demo-name', '');
return (
<div>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Your name" />
<p>Hello, {name || 'stranger'}.</p>
</div>
);
}
预览运行在一个沙盒框架中,浏览器在这里会阻止 localStorage,所以你会在 Console 里看到警告,Hook 则退回到普通的 state。在正常页面中,名字在刷新后依然保留。try/catch 不只是为了这个预览:在真实应用中,无痕浏览模式、被禁用的 Cookie 和存储配额已满都会让存储抛出错误。给 useState 传入一个函数,意味着存储只在首次渲染时读取一次。
useDebounce:等用户停止输入
搜索框不应该每按一次键就发一次请求。useDebounce 返回一个值的副本,只有在这个值停止变化并经过设定的延迟后才更新。
import { useState, useEffect } from 'react';
function useDebounce(value, delay) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
export default function App() {
const [text, setText] = useState('');
const query = useDebounce(text, 500);
useEffect(() => {
if (query) console.log('search for:', query);
}, [query]);
return (
<div>
<input value={text} onChange={(e) => setText(e.target.value)} placeholder="Search" />
<p>Typed: {text}</p>
<p>Debounced: {query}</p>
</div>
);
}
快速输入一个词:Typed 随每次按键变化,Debounced 在你停下半秒后才跟上,Console 只记录一次搜索。每次按键都会改变 value,所以 React 会先执行清理函数(clearTimeout),再启动新的定时器。把 500 改成 0,每次按键都会记录一次搜索。
useWindowWidth:订阅并清理
监听浏览器事件的 Hook 必须在组件消失时移除监听器,否则每次挂载都会留下一个。
import { useState, useEffect } from 'react';
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const onResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', onResize);
console.log('resize listener added');
return () => {
window.removeEventListener('resize', onResize);
console.log('resize listener removed');
};
}, []);
return width;
}
function WidthBadge() {
const width = useWindowWidth();
return <p>This frame is {width}px wide: {width < 500 ? 'narrow' : 'wide'} layout.</p>;
}
export default function App() {
const [show, setShow] = useState(true);
return (
<div>
<button onClick={() => setShow(!show)}>{show ? 'Unmount' : 'Mount'} badge</button>
{show && <WidthBadge />}
</div>
);
}
调整浏览器窗口大小,数字会随之变化。点击按钮,Console 会显示监听器被移除。把副作用里的 return 删掉,然后挂载、卸载几次:每次都会出现一行 added,却没有 removed,旧的监听器越积越多。对于这类浏览器的值,useSyncExternalStore 是 React 专门为此打造的 Hook,很适合在自定义 Hook 里调用。
共享的是逻辑,不是状态
两个组件调用同一个 Hook,会得到 Hook 内部所有东西的两份独立副本。这会让那些以为 Hook 像全局变量一样工作的人感到意外。
import { useState } from 'react';
function useCounter() {
const [count, setCount] = useState(0);
return [count, () => setCount((c) => c + 1)];
}
function Counter({ name }) {
const [count, increment] = useCounter();
return (
<p>
{name}: {count} <button onClick={increment}>+1</button>
</p>
);
}
export default function App() {
return (
<div>
<Counter name="First" />
<Counter name="Second" />
</div>
);
}
点击任意一个按钮,只有对应的计数器会变化。如果两边应该显示同一个数字,就把状态移到 App 里再向下传递,或者放进 context。自定义 Hook 可以为了方便而封装这个共享状态(useCart() 读取一个 CartContext),但共享来自 context,而不是 Hook。
什么时候写自定义 Hook
当同一组 Hook 调用出现在两个组件里,或者某个组件的副作用逻辑长到用一个名字来说明会更清楚时(useOnlineStatus() 比二十行监听器代码好读得多),就把它提取成 Hook。不要只为了改个名字就把单个 useState 调用包进 Hook,也不要写那种只调用 useEffect 从 props 设置状态的 Hook;应该在渲染时直接计算这个值。