React Context 与状态提升
在 App Router 中,Server Component 不能使用
useState/useContext,状态管理的边界变得更清晰。本章讲清状态应该放在哪、Context 的正确用法、Provider 的组合方式,以及什么时候不该用 Context。
1. App Router 中的状态分层
在传统 SPA 中,所有状态都在客户端管理;到了 App Router,服务端和客户端被强制区分——这反而让状态管理变得更清晰。
核心原则:能放服务端的状态,不要放客户端。
| 状态类型 | 归属 | 推荐方案 |
|---|---|---|
| 数据库查询结果 | 服务端 | Server Component async/await |
| 会话信息 | 服务端 | auth() / cookies() |
| 缓存数据 | 服务端 | unstable_cache / 'use cache' |
| UI 开关(模态框、侧边栏) | 客户端 | useState / Context |
| 全局 UI 状态(主题、通知) | 客户端 | Context / Zustand |
| 表单状态 | 客户端 | React Hook Form |
| 服务端数据的客户端缓存 | 客户端 | TanStack Query |
| 搜索/筛选/分页 | URL | useSearchParams / nuqs |
常见误区:
// ❌ 在客户端 fetch 用户信息,再存 Context
'use client'
const [user, setUser] = useState(null)
useEffect(() => { fetch('/api/user').then(...) }, [])
// ✅ 在 Server Component 直接查数据库,props 传给 Client
export default async function DashboardLayout({ children }) {
const user = await getUser() // 服务端直查
return <Header user={user} /> // props 传给 Client Component
}数据在服务端就能拿到,就不要绕到客户端再请求。这样做减少了一次网络往返,也避免了加载态闪烁。
2. React Context 实战
2.1 Context 的适用场景
Context 适合跨多层组件共享低频更新的 UI 状态。典型场景:
- 侧边栏折叠:侧边栏组件和主内容区都需要知道折叠状态
- 主题切换:全站所有组件都需要响应明/暗色变化
- 模态框管理:任何层级的组件都可能触发全局模态框
- 通知提示:任意位置触发 Toast 通知
不适合的场景:对话消息列表(高频更新)、用户信息(服务端数据)、搜索关键词(URL 状态)。
2.2 三件套模式
每个 Context 都遵循同样的三件套模式:createContext + Provider + 自定义 Hook。
// contexts/sidebar-context.tsx — 定义 Context + Provider + Hook 三件套
'use client'
import { createContext, use, useState, useCallback, useMemo } from 'react'
// 1️⃣ 类型定义
type SidebarContextType = {
collapsed: boolean
toggle: () => void
}
// 2️⃣ 创建 Context(默认值为 null,由 Hook 检查)
const SidebarContext = createContext<SidebarContextType | null>(null)
// 3️⃣ Provider 组件
export function SidebarProvider({ children }: { children: React.ReactNode }) {
const [collapsed, setCollapsed] = useState(false)
const toggle = useCallback(() => setCollapsed(prev => !prev), [])
// useMemo 稳定引用,避免 value 对象每次渲染都是新引用
const value = useMemo(() => ({ collapsed, toggle }), [collapsed, toggle])
return <SidebarContext value={value}>{children}</SidebarContext>
}
// 4️⃣ 自定义 Hook(封装类型检查 + 错误提示)
export function useSidebar() {
const context = use(SidebarContext) // React 19 用 use() 替代 useContext()
if (!context) throw new Error('useSidebar must be used within SidebarProvider')
return context
}为什么用 createContext(null) 而不是 createContext({ collapsed: false, toggle: () => {} })?因为默认值意味着"没有 Provider 包裹时也不会报错"——这会隐藏 bug。用 null + Hook 检查能在开发阶段立刻发现 Provider 缺失问题。
2.3 消费 Context
消费组件通过 useSidebar() 获取状态。注意消费者必须是 Client Component:
// components/sidebar.tsx
'use client'
import { useSidebar } from '@/contexts/sidebar-context'
import { PanelLeft } from 'lucide-react'
export function Sidebar() {
const { collapsed, toggle } = useSidebar()
return (
<aside className={cn('border-r transition-all duration-200', collapsed ? 'w-16' : 'w-64')}>
<button onClick={toggle} className="p-2 hover:bg-accent rounded">
<PanelLeft className="h-5 w-5" />
</button>
{!collapsed && <SidebarNav />}
</aside>
)
}2.4 在 Layout 中挂载
Server Component 可以包裹 Client Provider——这是 App Router 的组合模式:
// app/(dashboard)/layout.tsx — Server Component
import { SidebarProvider } from '@/contexts/sidebar-context'
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const session = await auth()
if (!session?.user) redirect('/login')
return (
<SidebarProvider>
<div className="flex h-screen">
<Sidebar />
<main className="flex-1 overflow-auto">{children}</main>
</div>
</SidebarProvider>
)
}Server Component(Layout)不会因为 SidebarProvider 内部状态变化而重渲染——只有消费了 useSidebar() 的 Client Component 才会重渲染。
3. Provider 组合与分层
3.1 分层策略
不是所有 Provider 都应该在根布局。按作用域分层放置:
app/layout.tsx (Root)
└── ThemeProvider ← 全站需要(主题切换)
└── QueryProvider ← 全站需要(TanStack Query)
app/(dashboard)/layout.tsx
└── SidebarProvider ← 只有仪表盘需要
app/(dashboard)/chat/layout.tsx
└── ChatProvider ← 只有对话页需要
好处:
- 对话页的
ChatProvider不会影响设置页的渲染 - 退出仪表盘时
SidebarProvider被卸载,释放内存 - 每层 Provider 关注自己的职责,避免根布局 Provider 爆炸
3.2 统一 Providers 文件
根布局的 Provider 可以收敛到一个文件,减少嵌套层级的视觉噪音:
// components/providers.tsx
'use client'
import { ThemeProvider } from 'next-themes'
import { QueryProvider } from '@/lib/query-provider'
import { Toaster } from '@/components/ui/sonner'
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider attribute="class" defaultTheme="system">
<QueryProvider>
{children}
<Toaster />
</QueryProvider>
</ThemeProvider>
)
}
// app/layout.tsx — 根布局使用
export default function RootLayout({ children }) {
return (
<html><body>
<Providers>{children}</Providers>
</body></html>
)
}3.3 Server → Client 数据传递
如果服务端有数据需要传给 Context,模式是 Server Component 查数据 → props 传给 Client Provider:
// app/(dashboard)/layout.tsx — Server Component 查数据
export default async function DashboardLayout({ children }) {
const user = await getUser()
return <UserProvider initialUser={user}>{children}</UserProvider>
}
// contexts/user-context.tsx — Client Provider 接收初始数据
'use client'
export function UserProvider({ children, initialUser }: { children: React.ReactNode; initialUser: User }) {
const [user, setUser] = useState(initialUser)
return (
<UserContext value={{ user, setUser }}>
{children}
</UserContext>
)
}这样用户信息在服务端获取(快,安全),在客户端通过 Context 分发(方便子组件读取),用户更新资料后用 setUser 本地更新。
4. Context 性能优化
4.1 核心问题
Context 最大的问题:value 任何字段变化,所有消费者都重渲染。
假设一个 AppContext 包含 { theme, user, sidebar }:
sidebar从false变为true- 只用了
theme的组件也会重渲染 ❌
4.2 优化策略
策略一:拆分 Context
// ❌ 一个大 Context
const AppContext = createContext({ theme: 'light', user: null, sidebar: false })
// ✅ 拆成独立 Context,各管各的
const ThemeContext = createContext<'light' | 'dark'>('light')
const UserContext = createContext<User | null>(null)
const SidebarContext = createContext<SidebarState>(...)策略二:稳定 value 引用
Provider 的 value 如果是对象字面量 {{ collapsed, toggle }},每次渲染都是新对象——即使内容没变也会触发消费者重渲染。用 useMemo 解决:
// ❌ 每次渲染都创建新对象
<SidebarContext value={{ collapsed, toggle }}>
// ✅ useMemo 确保对象引用稳定
const value = useMemo(() => ({ collapsed, toggle }), [collapsed, toggle])
<SidebarContext value={value}>策略三:高频状态用 Zustand 替代
如果一个状态每秒更新多次(AI 流式回复、实时计数器),不要用 Context——Zustand 的选择性订阅能精准避免无关组件重渲染。
5. 什么时候不该用 Context
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 父子共享(1-2 层) | Props 直传 | 最简单直接,无额外抽象 |
| 低频全局 UI 状态 | Context | 主题、侧边栏、模态框不频繁变化 |
| 高频更新(流式回复、计数器) | Zustand | 选择性订阅,不连带重渲染 |
| 服务端数据缓存 | TanStack Query | 自动缓存、去重、后台重验证 |
| 可分享/可刷新状态 | URL (nuqs) | 搜索、筛选、分页 |
| 复杂跨组件状态 | Zustand | 选择性订阅 + 中间件(persist/devtools) |
经验法则:如果你发现 Context 导致了不该重渲染的组件重渲染,那就该换 Zustand 了。
本章小结
- 状态分层:服务端状态用 Server Component,客户端状态用 Context/Zustand
- Context 适合:低频更新的全局 UI 状态(主题、侧边栏、模态框)
- 三件套模式:
createContext(null)+Provider+ 自定义useXxx()Hook - Provider 分层:全局放根布局,局部放对应 Layout,统一收敛到 Providers 文件
- Server → Client:Server Component 查数据 → props 传给 Client Provider
- 性能:拆小 Context +
useMemo稳定 value + 高频状态用 Zustand
下一章讲 Zustand 深度集成——解决 Context 在高频更新场景的性能问题。