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
搜索/筛选/分页URLuseSearchParams / 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(&#123; collapsed: false, toggle: () => &#123;&#125; &#125;)?因为默认值意味着"没有 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 包含 &#123; theme, user, sidebar &#125;

  • sidebarfalse 变为 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 如果是对象字面量 &#123;&#123; collapsed, toggle &#125;&#125;,每次渲染都是新对象——即使内容没变也会触发消费者重渲染。用 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 在高频更新场景的性能问题。