Next.js DApp 状态管理架构:Zustand 与 Jotai 的原子化设计与协同方案
Next.js DApp 状态管理架构Zustand 与 Jotai 的原子化设计与协同方案一、DApp 状态管理的多域交叉依赖问题DApp 前端的状态管理核心挑战在于多域状态的交叉依赖与一致性约束。一个典型的 DeFi 应用需要同时持有四类状态域钱包连接地址、链 ID、原生代币余额、合约交互approve 授权进度、swap 交易回执与 nonce 序列、链上数据缓存价格喂价源、TVL 快照、以及 UI 交互状态弹窗层级、表单填充缓存。这些域之间存在强制协调关系——链 ID 切换时必须同步失效所有合约地址映射和余额缓存交易 pending 期间需要锁定网络切换以防止 nonce 序号错乱。传统的 Redux 方案在 DApp 场景下面临两个核心问题一是全局单一 store 导致无关状态变更触发不必要的组件重渲染一个价格 ticker 的更新不应让整个 Swap 表单重渲染二是 Provider 包裹的层级结构在 Next.js 的 RSCReact Server Components架构中难以优雅嵌入。本文将探讨 Zustand 和 Jotai 这两种原子化状态管理方案在 DApp 中的协同使用Zustand 管理全局性的、需要中间件的状态钱包连接、链切换Jotai 管理细粒度的、派生性的状态单个 token 价格、用户持仓。flowchart TB subgraph Zustand层[Zustand - 全局状态] WALLET[钱包 Storebr/地址/链ID/连接状态] TX[交易 Storebr/pending/completed/failed 队列] SETTINGS[设置 Storebr/滑点/Deadline/语言] end subgraph Jotai层[Jotai - 派生状态] BALANCE[余额 Atombr/依赖: 地址链ID] PRICE[价格 Atombr/依赖: Token地址链] ALLOWANCE[授权 Atombr/依赖: TokenSpender] SWAP_QUOTE[报价 Atombr/依赖: 输入/输出Token数量] end subgraph React组件层 COMPONENTS[Navbar / SwapForm / Portfolio / Settings] end WALLET --|地址变更| BALANCE WALLET --|链切换| PRICE WALLET --|连接状态| ALLOWANCE TX --|交易提交| SWAP_QUOTE COMPONENTS --|subscribe| BALANCE COMPONENTS --|subscribe| PRICE COMPONENTS --|useStore| WALLET COMPONENTS --|useStore| TX这张图揭示了一个关键设计全局状态Zustand作为信号源派生状态Jotai作为信号接收器组件只订阅自己真正关心的状态片段。二、Zustand 与 Jotai 的分工边界与组合范式2.1 Zustand 的适用场景Zustand 的 store 是一个外部可变容器不需要 Provider 包裹天生适配 Next.js 的服务端/客户端组件边界。它适合管理以下类型的状态连接生命周期connect()/disconnect()/switchChain()这类有明确触发动作且影响多个下游状态的操作需要中间件的逻辑persist持久化到 localStorage、immer不可变更新、devtools调试跨组件的全局事件交易队列、通知列表2.2 Jotai 的适用场景Jotai 的核心是原子atom——一个最小化的状态单元多个 atom 可以通过依赖关系自动派生。它适合管理派生数据余额 f(地址, 链ID, RPC 查询)可组合的 UI 状态isSwappingAtom可派生自swapTxStatusAtom细粒度订阅只订阅 tokenA 的价格变化不影响 tokenB 的组件2.3 协同模式Zustand 作为 Jotai 的信号输入协同的关键在于Zustand store 的变更如何触发 Jotai atom 的重新计算。方案是使用 Zustand 的subscribeAPI 配合 Jotai 的atom派生能力Zustand Store (地址变更) → Jotai Atom (派生余额) → React Component (仅重渲染余额相关部分)这形成了一个单向数据流全局状态 → 派生状态 → UI 渲染。不存在从 Jotai atom 反向写入 Zustand store 的路径保证了数据流的可追踪性。三、代码实践钱包 DApp 的完整状态架构/** * 钱包状态管理架构 * * 设计决策 * - Zustand 管理连接生命周期和交易队列需要中间件持久化 * - Jotai 管理派生状态余额/价格/授权依赖 Zustand 的地址和链 * - 严格单向数据流Zustand → Jotai → Component * - 避免在 Jotai atom 中直接修改 Zustand store破坏单向流 */ // Zustand Stores import { create } from zustand; import { persist, subscribeWithSelector } from zustand/middleware; import { atom, useAtomValue, useSetAtom } from jotai; import { atomWithObservable } from jotai/utils; // --- 钱包 Store --- interface WalletState { address: 0x${string} | null; chainId: number | null; isConnecting: boolean; connect: () Promisevoid; disconnect: () void; switchChain: (chainId: number) Promisevoid; } export const useWalletStore createWalletState()( subscribeWithSelector( // 启用 subscribe API供 Jotai 监听 persist( // 持久化连接状态到 localStorage (set, get) ({ address: null, chainId: null, isConnecting: false, connect: async () { set({ isConnecting: true }); try { // 实际项目中接入 wagmi/viem const accounts await window.ethereum!.request({ method: eth_requestAccounts }) as string[]; const chainId await window.ethereum!.request({ method: eth_chainId }) as string; set({ address: accounts[0] as 0x${string}, chainId: parseInt(chainId, 16), isConnecting: false }); } catch { set({ isConnecting: false }); } }, disconnect: () { set({ address: null, chainId: null }); }, switchChain: async (chainId: number) { try { await window.ethereum!.request({ method: wallet_switchEthereumChain, params: [{ chainId: 0x${chainId.toString(16)} }] }); set({ chainId }); } catch { // 链未添加时的错误处理 } } }), { name: wallet-storage, // 只持久化不影响 UI 重新渲染的静态字段 partialize: (state) ({ chainId: state.chainId }) } ) ) ); // --- 交易 Store --- interface TxRecord { hash: 0x${string}; description: string; status: pending | confirmed | failed; timestamp: number; } interface TxState { transactions: TxRecord[]; addTx: (tx: OmitTxRecord, status | timestamp) void; updateTx: (hash: 0x${string}, status: TxRecord[status]) void; clearHistory: () void; } export const useTxStore createTxState()( persist( (set, get) ({ transactions: [], addTx: (tx) set((state) ({ transactions: [ { ...tx, status: pending, timestamp: Date.now() }, ...state.transactions ].slice(0, 50) // 限制历史记录数量避免 storage 膨胀 })), updateTx: (hash, status) set((state) ({ transactions: state.transactions.map(t t.hash hash ? { ...t, status } : t ) })), clearHistory: () set({ transactions: [] }) }), { name: tx-storage } ) ); // Jotai Atoms import { ethers } from ethers; /** * 创建监听 Zustand store 变化的 observable atom * 设计决策使用 Jotai 的 atomWithObservable 桥接 Zustand 的 subscribe API * 这样 Zustand 的变更会自动触发依赖该 atom 的所有组件按需重渲染 */ function atomFromZustandS, T( store: { getState: () S; subscribe: (listener: (s: S) void) () void }, selector: (state: S) T ) { const baseAtom atom(selector(store.getState())); baseAtom.onMount (setAtom) { const unsubscribe store.subscribe((newState) { setAtom(selector(newState)); }); return unsubscribe; }; return baseAtom; } // 从 Zustand wallet store 派生的 Jotai atoms export const addressAtom atomFromZustand( useWalletStore, (s) s.address ); export const chainIdAtom atomFromZustand( useWalletStore, (s) s.chainId ); /** * 余额 Atom派生自 address 和 chainId * 设计决策当 address 或 chainId 变化时自动重新计算 * 使用 SWR 风格优先返回缓存值后台刷新链上数据 */ export const balanceAtom atom{ value: string; loading: boolean; error: string | null }({ value: 0, loading: false, error: null }); export const refreshBalanceAtom atom( null, async (get, set) { const address get(addressAtom); const chainId get(chainIdAtom); if (!address || !chainId) { set(balanceAtom, { value: 0, loading: false, error: null }); return; } set(balanceAtom, (prev) ({ ...prev, loading: true })); try { const provider new ethers.JsonRpcProvider(getRpcUrl(chainId)); const balance await provider.getBalance(address); set(balanceAtom, { value: ethers.formatEther(balance), loading: false, error: null }); } catch (err) { set(balanceAtom, { value: 0, loading: false, error: (err as Error).message }); } } ); /** * Token 授权 Atom * 设计决策独立的 token atom 避免全局性更新 * 每个 token 的授权状态独立缓存切换 token 时不重新拉取其他 token 的数据 */ function createAllowanceAtom(tokenAddress: 0x${string}) { return atom{ allowance: string; loading: boolean }({ allowance: 0, loading: false }); } // 为不同 token 创建独立的 atom 实例 export const usdcAllowanceAtom createAllowanceAtom( 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 // USDC ); export const usdtAllowanceAtom createAllowanceAtom( 0xdAC17F958D2ee523a2206206994597C13D831ec7 // USDT ); // 组件使用示例 /** * Navbar 组件使用 Zustand 的 selector 实现精准订阅 * 设计决策使用 shallow 比较避免对象引用变化导致的不必要渲染 */ import { useShallow } from zustand/react/shallow; function Navbar() { const { address, isConnecting, connect, disconnect } useWalletStore( useShallow((s) ({ address: s.address, isConnecting: s.isConnecting, connect: s.connect, disconnect: s.disconnect })) ); // 余额使用 Jotai只在余额相关组件内重渲染 const balance useAtomValue(balanceAtom); const refreshBalance useSetAtom(refreshBalanceAtom); // 连接/断开时自动刷新余额 useEffect(() { if (address) refreshBalance(); }, [address, refreshBalance]); return ( nav {address ? ( span{balance.value} ETH/span span{truncateAddress(address)}/span button onClick{disconnect}断开/button / ) : ( button onClick{connect} disabled{isConnecting} {isConnecting ? 连接中... : 连接钱包} /button )} /nav ); } // 辅助函数 function truncateAddress(addr: string): string { return ${addr.slice(0, 6)}...${addr.slice(-4)}; } function getRpcUrl(chainId: number): string { const RPC_URLS: Recordnumber, string { 1: https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY, 137: https://polygon-mainnet.g.alchemy.com/v2/YOUR_KEY, 42161: https://arb-mainnet.g.alchemy.com/v2/YOUR_KEY, }; return RPC_URLS[chainId] || RPC_URLS[1]; }四、边界分析原子化设计的代价与妥协边界一atom 爆炸问题Jotai 的原子化哲学意味着每个独立的数据单元都可能成为一个 atom。当应用有 100 个 token、每个 token 有价格/余额/授权三个 atom 时atom 数量呈线性增长。解决方案是按需懒初始化——只在用户访问某个 token 的 Swap 界面时才创建对应的 atom 家族。边界二跨 Store 的事务一致性当一笔 Swap 交易同时涉及useWalletStore更新 nonce、useTxStore记录交易和balanceAtom刷新余额时如果其中任意一步失败需要保证整体回滚。Zustand 支持setState的批量更新但 Jotai atom 的写入是独立的。解决方案是引入一个事务协调 atomconst swapTransactionAtom atom(null, async (get, set, params) { set(swapStatusAtom, executing); try { // 所有子步骤串行执行任一步失败 throw await executeSwap(params); set(swapStatusAtom, success); } catch { set(swapStatusAtom, failed); // 清理临时状态 } });边界三Next.js RSC 的序列化限制React Server Components 中不能使用 hooks包括 Zustand 和 Jotai。而 DApp 的核心状态钱包地址本身就是客户端状态。策略是服务端只渲染骨架 UI所有交互状态在客户端水合后填充。使用use client指令明确标记客户端组件边界。边界四内存泄漏风险Jotai 的atomWithObservable在组件卸载后如果未正确清理 subscription会导致 Zustand store 的 listener 不断累积。onMount的返回值unsubscribe 函数必须正确返回。五、总结Zustand 与 Jotai 的组合使用为 DApp 提供了一种分层清晰的状态管理架构Zustand 负责源状态钱包连接、链切换、交易历史——这些有明确生命周期和动作触发的状态适合用 store 管理Jotai 负责派生状态余额、价格、授权——这些依赖源状态且需要细粒度更新的数据适合用 atom 表达单向数据流Zustand → Jotai → Component避免反向写入精准渲染利用 Zustand 的 selector 和 Jotai 的原子订阅确保组件只在自己关心的数据变化时才重渲染这种架构的核心价值在于它将状态的变化传播与UI 的重渲染范围解耦。当链上价格每秒刷新 3 次时只有确实显示了价格的组件会更新——Swap 表单的输入框保持不变网络切换按钮也毫发无损。