尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
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 表单的输入框保持不变网络切换按钮也毫发无损。
RELATED

相关推荐

C语言实现代码行统计工具:状态机与文件I/O实战

C语言实现代码行统计工具:状态机与文件I/O实战

1. 项目概述与核心价值最近在带几个学生做课程设计,发现一个挺有意思的现象:很多同学在完成“文件处理”或“系统编程”这类题目时,总想着去网上找现成的轮子,或者直接调用wc -l命令应付了事。这让我想起自己当年学C语言和Linux系…

📅 2026/9/12 14:53:40
(十五)在判定假设下加密的安全证明(二)

(十五)在判定假设下加密的安全证明(二)

优势计算2 通过上一篇博客的分析:在判定假设下加密的安全证明 解决底层困难问题的优势可以表示为: 插一段分析 这个式子是在定义模拟器判断Z真假的优势 Pr[Guess Z=True∣Z=True]表示当Z实际上为真,模拟器猜测真的概率 Pr[Guess Z=True∣Z=False]表示当Z为假的时候,模拟器…

📅 2026/10/22 0:04:48
Windows 本地搭建 CDC 全链路测试环境(Kafka+Canel+binlog+SpringBoot)

Windows 本地搭建 CDC 全链路测试环境(Kafka+Canel+binlog+SpringBoot)

Windows 本地搭建 CDC 全链路测试环境(KafkaCanelbinlogSpringBoot)MySQL Binlog CDC 全链路实现详解: https://blog.csdn.net/BADAO_LIUMANG_QIZHI/article/details/162845686 前提条件 Windows 电脑MySQL 8 已安装(端口 3306)JDK 8 或 11 已安装&#…

📅 2026/10/22 0:07:23
MORE NEWS

更多资讯

📰

联邦学习MNIST实战:Dirichlet划分与FedAvg排错指南

简介:这份压缩包面向联邦学习、分布式训练与隐私保护方向的初学者和项目实践者,围绕MNIST手写数字识别给出可运行的联邦学习与差分隐私实验材料,适合用于课程设计、入门复现和算法对比。包内共17个文件,约56.29MB,主要…

📰

iOS 手搓轻量物理引擎:浮动气泡圆碰撞检测与网格分桶实战

简介:这是一份面向iOS开发者的浮动泡泡功能示例工程,围绕自定义视图绘制、动画驱动与碰撞反弹展开,适合已掌握基础UIKit、希望练习Core Graphics绘图和基础物理检测的中级学习者参考。压缩包共24个文件、约7.34MB,含m与h源码、sto…

📰

ASP.NET餐饮管理系统源码实战:部署、排错与二次开发

简介:面向餐饮行业信息化开发学习者与中小餐厅管理人员的 ASP.NET 餐饮管理系统完整项目包,以 Web 形式实现菜单、订单、库存、会员、报表与后台权限等常见业务,可作为课程设计、毕业设计或企业原型参考。包内共约 2000 个文件、整体 46.58MB…

📰

secureCRT-7.0.0实战:SSH2公钥、端口转发与日志脚本配置

简介:SecureCRT 7.0.0 是面向 IT 运维、网络管理员及开发测试人员的 64 位终端仿真与文件传输工具包,主要用于 shell 远程登录、服务器维护及多协议连接管理。压缩包共93个文件,约15.86MB,含26个dll运行库、14个vbs与6个py脚本、5…

📰

ASP.NET餐饮管理系统:从桌台订单事务到扫码点餐

简介:面向餐饮行业信息化入门与课程实践的ASP.NET餐饮管理系统完整源码包,基于ASP.NET Web Forms与三层架构结合SQL Server 2008构建,涵盖菜单、订单、库存、会员、报表及后台权限等常见业务模块,适合计算机专业学生用作课程设计、…

📰

AI 开始攻击机械方向了??

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬