Custom Hook(自定义 Hook)你可以把它理解成:把一段“可复用的逻辑 + 状态/副作用”封装成一个函数,以后任何组件想用这段逻辑,直接 useXxx() 一行就行。
1)Custom Hook 是什么(一句话)
Custom Hook = “把一套 hooks 组合起来”做成一个可复用的函数。
-
它本质就是普通 JS 函数
-
但命名要 useSomething(React 规则/检查依赖它)
-
里面可以用 useState/useEffect/useRef/… 等 hook
-
返回值你自己定(可以像 useState 一样返回 [value, setValue],也可以返回对象)
2)它解决的痛点是什么?
以前你会在很多组件里反复写同一套逻辑,比如:
-
本地存储同步:初始化读 localStorage + value 变了写回去
-
请求:loading/error/data + abort + retry
-
Web3:钱包连接状态、链切换、余额刷新、事件订阅
-
表单:校验、脏值、提交
-
DOM:聚焦、滚动、监听 resize
如果不封装,每个组件都写一遍,又长又容易写错。
Custom Hook 的价值就是:
✅ 复用 + ✅ 隔离复杂度 + ✅ 统一行为(所有地方都一致)
3)例子 1:useLocalStorage(像 useState 一样用,但自动持久化)
你要的效果
const [name, setName] = useLocalStorage("name", "")
-
刷新页面也不丢
-
用法跟 useState 一样舒服
一个标准实现(更接近生产写法)
import { useEffect, useState } from "react"
export default function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
const json = localStorage.getItem(key)
if (json != null) return JSON.parse(json)
// 支持 initialValue 是函数(和 useState 一致)
return typeof initialValue === "function" ? initialValue() : initialValue
})
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value))
}, [key, value])
return [value, setValue]
}
关键点:
-
useState(() => …):懒初始化,只读一次 localStorage(避免每次 render 都读)
-
useEffect:value 改了就写回 localStorage
-
返回 [value, setValue]:让它像 useState 一样易用
4)例子 2:useUpdateLogger(把“监听变化”封装成一行)
import { useEffect, useRef } from "react"
export default function useUpdateLogger(value, label = "value") {
const first = useRef(true)
useEffect(() => {
if (first.current) {
first.current = false
return
}
console.log(`[${label}] changed:`, value)
}, [value, label])
}
用法:
useUpdateLogger(name, "name")
useUpdateLogger(chainId, "chainId")
这就是 custom hook 的典型套路:
把“副作用 + 条件判断 + 细节”藏起来,组件里只剩一行调用。
5)Custom Hook 的“规则”你只要记 3 个
名字以 use 开头(否则 lint 和 React rules 识别不了)
只能在 Hook/组件顶层调用 Hook(不能 if/for 里调用)
Custom Hook 里可以调用任何 Hook(组合能力就是它强的原因)
6)Web3 前端里最常写的 Custom Hook(实战方向)
A. useWalletReducer.ts(连接/断开/切链/更新余额)
import { useCallback, useEffect, useMemo, useReducer } from "react"
import {
useAccount,
useAccountEffect,
useBalance as useWagmiBalance,
useChainId,
useConnect,
useDisconnect,
useSwitchChain,
type Connector,
} from "wagmi"
type WalletStatus = "idle" | "connecting" | "connected" | "error"
export type WalletState = {
address?: `0x${string}`
chainId?: number
status: WalletStatus
balance?: {
formatted: string
symbol: string
value: bigint
}
error?: string
}
type Action =
| { type: "connectStart" }
| { type: "connectOk"; payload: { address: `0x${string}`; chainId?: number } }
| { type: "connectFail"; payload: { error: string } }
| { type: "disconnect" }
| { type: "chainChanged"; payload: { chainId: number } }
| { type: "balanceUpdated"; payload: WalletState["balance"] }
const initialState: WalletState = {
status: "idle",
}
function reducer(state: WalletState, action: Action): WalletState {
switch (action.type) {
case "connectStart":
return { …state, status: "connecting", error: undefined }
case "connectOk":
return {
…state,
status: "connected",
address: action.payload.address,
chainId: action.payload.chainId ?? state.chainId,
error: undefined,
}
case "connectFail":
return { …state, status: "error", error: action.payload.error }
case "disconnect":
return { status: "idle" }
case "chainChanged":
return { …state, chainId: action.payload.chainId }
case "balanceUpdated":
return { …state, balance: action.payload ?? undefined }
default:
return state
}
}
/**
* useWalletReducer:
* – reducer 统一管理允许发生的状态变化(动作集合)
* – wagmi 提供真实连接信息 & 动作函数
*/
export function useWalletReducer() {
const [state, dispatch] = useReducer(reducer, initialState)
const chainId = useChainId()
const account = useAccount() // { address, status, … } [oai_citation:2‡2.x.wagmi.sh](https://2.x.wagmi.sh/react/api/hooks/useAccount)
const { connectors, connectAsync } = useConnect() // [oai_citation:3‡2.x.wagmi.sh](https://2.x.wagmi.sh/react/api/hooks/useBalance)
const { disconnectAsync } = useDisconnect()
const { switchChainAsync, chains } = useSwitchChain()
// 用 wagmi 的 useBalance 获取原生币余额(也可扩展 token)
const balanceQuery = useWagmiBalance({
address: account.address,
chainId,
query: {
enabled: Boolean(account.address),
// 你也可以放 refetchInterval/staleTime/retry 等
},
}) // useBalance query options [oai_citation:4‡2.x.wagmi.sh](https://2.x.wagmi.sh/react/api/hooks/useBalance)
// ✅ 监听连接/断开:wagmi 提供专门的 effect hook 更稳
useAccountEffect({
onConnect(data) {
// data.address / data.chainId … [oai_citation:5‡2.x.wagmi.sh](https://2.x.wagmi.sh/react/api/hooks/useAccountEffect)
dispatch({
type: "connectOk",
payload: { address: data.address as `0x${string}`, chainId: data.chainId },
})
},
onDisconnect() {
dispatch({ type: "disconnect" })
},
}) // [oai_citation:6‡2.x.wagmi.sh](https://2.x.wagmi.sh/react/api/hooks/useAccountEffect)
// chainId 变化 → reducer 里更新
useEffect(() => {
dispatch({ type: "chainChanged", payload: { chainId } })
}, [chainId])
// 余额更新 → reducer 里更新
useEffect(() => {
if (!balanceQuery.data) return
dispatch({
type: "balanceUpdated",
payload: {
formatted: balanceQuery.data.formatted,
symbol: balanceQuery.data.symbol,
value: balanceQuery.data.value,
},
})
}, [balanceQuery.data])
// 动作:连接
const connect = useCallback(
async (connector?: Connector) => {
dispatch({ type: "connectStart" })
try {
const result = await connectAsync(connector ? { connector } : undefined)
// connectAsync 返回 account 等信息(不同版本字段略有差异)
dispatch({
type: "connectOk",
payload: { address: result.accounts?.[0] as `0x${string}`, chainId: result.chainId },
})
return result
} catch (e: any) {
dispatch({ type: "connectFail", payload: { error: e?.message ?? String(e) } })
throw e
}
},
[connectAsync],
)
// 动作:断开
const disconnect = useCallback(async () => {
await disconnectAsync()
dispatch({ type: "disconnect" })
}, [disconnectAsync])
// 动作:切链
const switchChain = useCallback(
async (targetChainId: number) => {
await switchChainAsync({ chainId: targetChainId })
dispatch({ type: "chainChanged", payload: { chainId: targetChainId } })
},
[switchChainAsync],
)
// 给 UI 更好用的派生值
const derived = useMemo(() => {
return {
isConnected: state.status === "connected" && Boolean(state.address),
isConnecting: state.status === "connecting",
canSwitch: state.status === "connected",
}
}, [state.status, state.address])
return {
state,
…derived,
connectors,
chains,
connect,
disconnect,
switchChain,
// 你也可以把 wagmi query 原样抛出去给页面用
balanceQuery,
}
}
B. useBalance.ts(wagmi + viem,含竞态/重试/节流)
这个 hook 不用 wagmi 自带 useBalance,而是用 usePublicClient().getBalance() 手动拉,这样你才能把“竞态/重试/节流/触发时机”控制得很清楚。
import { useCallback, useEffect, useRef, useState } from "react"
import { formatUnits } from "viem"
import { usePublicClient } from "wagmi"
type BalanceResult = {
value: bigint
formatted: string
symbol: string
decimals: number
}
type UseBalanceOptions = {
address?: `0x${string}`
enabled?: boolean
chainId?: number // 可选:你也可以不传,client 会按当前 chain
throttleMs?: number
retry?: number
retryDelayMs?: number
symbol?: string
decimals?: number
onBalance?: (b: BalanceResult) => void // 用来“喂” reducer
}
/**
* useBalance(实战增强版)
* – 竞态:只认最后一次请求(requestId)
* – 重试:失败自动重试 N 次
* – 节流:短时间多次触发只执行一次
*/
export function useBalance({
address,
enabled = true,
chainId,
throttleMs = 800,
retry = 2,
retryDelayMs = 500,
symbol = "ETH",
decimals = 18,
onBalance,
}: UseBalanceOptions) {
const publicClient = usePublicClient({ chainId })
const requestIdRef = useRef(0)
const lastRunAtRef = useRef(0)
const throttleTimerRef = useRef<number | null>(null)
const [data, setData] = useState<BalanceResult | undefined>(undefined)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | undefined>(undefined)
const doFetch = useCallback(async () => {
if (!enabled || !address || !publicClient) return
const myId = ++requestIdRef.current
setLoading(true)
setError(undefined)
let attempt = 0
while (attempt <= retry) {
try {
const value = await publicClient.getBalance({ address })
// 竞态保护:如果这次不是最新请求,直接丢弃
if (myId !== requestIdRef.current) return
const result: BalanceResult = {
value,
formatted: formatUnits(value, decimals),
symbol,
decimals,
}
setData(result)
onBalance?.(result)
setLoading(false)
return
} catch (e: any) {
attempt++
if (attempt > retry) {
if (myId !== requestIdRef.current) return
setError(e?.message ?? "Failed to fetch balance")
setLoading(false)
return
}
// 等一会再重试
await new Promise((r) => setTimeout(r, retryDelayMs))
}
}
}, [enabled, address, publicClient, retry, retryDelayMs, symbol, decimals, onBalance])
// 节流版 refresh(UI/监听里可以疯狂调用 refresh,但不会疯狂请求)
const refresh = useCallback(() => {
const now = Date.now()
const elapsed = now – lastRunAtRef.current
const run = () => {
lastRunAtRef.current = Date.now()
void doFetch()
}
if (elapsed >= throttleMs) {
run()
return
}
// 在 throttle 窗口内:只保留最后一次触发
if (throttleTimerRef.current) window.clearTimeout(throttleTimerRef.current)
throttleTimerRef.current = window.setTimeout(run, throttleMs – elapsed)
}, [doFetch, throttleMs])
// 地址/链变化时自动拉一次
useEffect(() => {
refresh()
return () => {
if (throttleTimerRef.current) window.clearTimeout(throttleTimerRef.current)
}
}, [refresh])
return { data, loading, error, refresh }
}
C. 把它们组合起来用(wagmi 组件示例)
这里演示:
-
连接状态来自 wagmi(useAccount/useChainId)
-
但业务状态你用 reducer 管(更清晰)
-
余额用 useBalance 拉完后 balanceUpdated 回写 reducer
import { useEffect } from "react"
import { useAccount, useChainId, useDisconnect } from "wagmi"
import { useWalletReducer } from "./useWalletReducer"
import { useBalance } from "./useBalance"
export default function WalletPanel() {
const { state, actions, derived } = useWalletReducer()
const { address, isConnected, status } = useAccount()
const chainId = useChainId()
const { disconnect } = useDisconnect()
// 把 wagmi 的状态“翻译”成你的 reducer 状态(你自己的业务口径)
useEffect(() => {
if (status === "connecting") actions.connectStart()
if (isConnected && address) {
actions.connectOk(address, chainId)
}
if (!isConnected) {
actions.disconnect()
}
}, [status, isConnected, address, chainId, actions])
// 链切换同步
useEffect(() => {
if (chainId) actions.chainChanged(chainId)
}, [chainId, actions])
// 拉余额(并回写 reducer)
const { data: bal, loading, error, refresh } = useBalance({
address: state.address,
enabled: derived.isConnected,
chainId: state.chainId,
throttleMs: 1000,
retry: 2,
onBalance: (b) => actions.balanceUpdated(b),
})
return (
<div style={{ padding: 12, border: "1px solid #ddd", borderRadius: 8 }}>
<div>Status: {state.status}</div>
<div>Address: {state.address ?? "-"}</div>
<div>ChainId: {state.chainId ?? "-"}</div>
<div style={{ marginTop: 8 }}>
Balance:{" "}
{loading ? "Loading…" : state.balance ? `${state.balance.formatted} ${state.balance.symbol}` : "-"}
</div>
{error && <div style={{ color: "crimson" }}>Balance error: {error}</div>}
<div style={{ marginTop: 12, display: "flex", gap: 8 }}>
<button onClick={() => refresh()} disabled={!derived.isConnected}>
Refresh Balance
</button>
<button
onClick={() => {
disconnect()
actions.disconnect()
}}
disabled={!derived.isConnected}
>
Disconnect
</button>
</div>
<pre style={{ marginTop: 12, background: "#f7f7f7", padding: 8 }}>
{JSON.stringify({ state, bal }, null, 2)}
</pre>
</div>
)
}
7)一句话总结
Custom Hook 就是把“状态/副作用/缓存/订阅”这种逻辑封装成一个 useXxx(),让组件只关心 UI,而不关心这些复杂实现。




