欢迎光临
我们一直在努力

Custom Hook的理解与应用

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,而不关心这些复杂实现。

    赞(0)
    未经允许不得转载:171主机测评 » Custom Hook的理解与应用
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址