【声明式UI开发实用技术学与练】第2课 状态建模:把界面拆成可预测的数据

适用技术栈:React、Vue、SwiftUI、Flutter、Jetpack Compose。以 React 为主要教学语言,思维跨框架通用。

核心理念:不讲空理论,每个知识点都落地到能直接用于工作的实用技能。

本课产出: 拿到任何页面,能在 10 分钟内列出它的最小状态清单;能识别冗余状态、冲突状态、放错位置的状态;能熟练运用不可变更新的全部模式;能用状态机处理互斥状态。

一句话预览: 声明式 UI 的代码质量,80% 取决于状态建得好不好。状态建对了,组件自然清晰;状态建错了,再多技巧也救不回来。


一、本课目标

学完本课,你应该能:

  1. 用三个判断标准,快速区分"状态"和"非状态"。
  2. 用五类状态框架,给任何页面的状态分类。
  3. 用五步法,从需求中提取最小状态集。
  4. 识别并消除四类问题状态:冗余状态、冲突状态、放错位置的状态、可派生却存了的状态。
  5. 掌握不可变更新的完整模式:数组增删改查、对象增删改查、嵌套更新。
  6. 用状态机替代多布尔,从结构上杜绝非法状态。
  7. 判断一个状态该放在组件树的哪个位置。

二、从一个真实痛点说起

场景:一个商品列表页

需求:展示商品列表,支持搜索、筛选、排序、分页,点击商品加入购物车。

一个开发者这样写:

jsx 复制代码
function ProductList() {
  const [products, setProducts] = useState([])
  const [keyword, setKeyword] = useState("")
  const [filteredProducts, setFilteredProducts] = useState([])
  const [sortedProducts, setSortedProducts] = useState([])
  const [displayProducts, setDisplayProducts] = useState([])
  const [page, setPage] = useState(1)
  const [pageCount, setPageCount] = useState(0)
  const [cartCount, setCartCount] = useState(0)
  const [cartTotal, setCartTotal] = useState(0)
  const [isLoading, setIsLoading] = useState(false)
  const [isError, setIsError] = useState(false)
  const [isEmpty, setIsEmpty] = useState(false)

  // ... 大量同步逻辑
}

11 个状态。每个状态都需要手动同步:

  • 搜索时,要更新 filteredProducts、displayProducts、pageCount、isEmpty。
  • 排序时,要更新 sortedProducts、displayProducts。
  • 翻页时,要更新 displayProducts。
  • 加购物车时,要更新 cartCount、cartTotal。
  • 请求开始时,要设置 isLoading、isError、isEmpty 三个状态互相配合。

这段代码的 bug 会多到无法维护。 不是开发者水平不行,而是状态建模从一开始就错了。

问题出在哪

逐条分析:

  1. filteredProducts、sortedProducts、displayProducts 都是派生的。 它们能从 products、keyword、page 算出来,根本不该单独存。
  2. cartCount、cartTotal 是派生的。 从购物车数据算出来就行。
  3. isLoading、isError、isEmpty 是互斥的。 它们应该合并成一个状态机。
  4. pageCount 是派生的。 从 total 和 pageSize 算出来。

真正的最小状态可能只有 5 个:products、keyword、page、cart、requestStatus。

这就是本课要教的核心技能:从一堆状态中,找出真正的最小真相。


三、什么是状态

状态的定义

状态是组件需要记住的、会随时间变化的最小事实。

三个关键词,缺一不可:

  • 记住: 跨渲染保留。普通变量每次渲染都会重置,状态不会。
  • 变化: 固定不变的不算状态。
  • 最小事实: 不能再从其他状态推导。

三个判断标准

对每一个候选状态,问三个问题:

  1. 它会随时间变化吗? 不会 → 不是状态,是常量。
  2. 它能从其他状态算出来吗? 能 → 不是状态,是派生。
  3. 它影响渲染结果吗? 不影响 → 可能不需要状态,或者应该放在 ref 里。

三个都"是",它才是状态。

一个反例

jsx 复制代码
// ❌ 这三个都不是状态
const [fullName, setFullName] = useState("")           // 能派生
const [listLength, setListLength] = useState(0)        // 能派生
const [API_URL] = useState("https://api.example.com")  // 不变,是常量

// ✅ 这三个才是
const [firstName, setFirstName] = useState("")         // 输入,会变,不可派生
const [lastName, setLastName] = useState("")           // 输入,会变,不可派生
const [items, setItems] = useState([])                 // 数据,会变,不可派生

一个容易忽略的例外

有一个例外:如果计算代价极高,可以缓存。但缓存不是状态,是性能优化。

jsx 复制代码
// ❌ 把重计算的结果当状态,还要手动同步
const [result, setResult] = useState([])
useEffect(() => {
  setResult(expensiveCompute(data))
}, [data])

// ✅ 用 useMemo 缓存,本质还是派生
const result = useMemo(() => expensiveCompute(data), [data])

useMemo 返回的仍然是派生值,只是加了缓存。它不是状态,因为它不需要 setX,也不会独立变化。


四、五类状态:给状态分类

状态不是铁板一块。按来源和用途,可以分成五类。分类的目的,是知道每类该放在哪里、该怎么处理。

1. 输入状态

定义: 用户直接产生的数据。

例子:

  • 输入框内容:keyword、email、password
  • 选择器选中值:selectedCategory、sortBy
  • 开关状态:showCompleted、darkMode
  • 分页器:page、pageSize
  • 拖拽位置:draggingId、dragOverIndex

特点:

  • 用户是唯一来源。
  • 变化频繁(每次按键都变)。
  • 通常需要防抖或受控组件配合。

放置位置: 使用它的组件,或最近的共同父级。

典型处理:

jsx 复制代码
const [keyword, setKeyword] = useState("")
const [debouncedKeyword, setDebouncedKeyword] = useState(keyword)

useEffect(() => {
  const timer = setTimeout(() => setDebouncedKeyword(keyword), 300)
  return () => clearTimeout(timer)
}, [keyword])

2. 异步状态

定义: 从外部(主要是服务端)获取的数据及其请求状态。

例子:

  • 请求返回的数据:products、user、comments
  • 请求状态:status(idle / loading / success / error)
  • 错误信息:error
  • 分页信息:total、hasMore

特点:

  • 有生命周期:发起 → 等待 → 成功/失败。
  • 有竞态问题:后发的请求可能先返回。
  • 通常需要缓存和去重。

放置位置: 页面级或全局,取决于共享范围。

关键原则:请求状态用状态机,不要用多个布尔。

jsx 复制代码
// ❌ 三个布尔可能同时为 true,也可能同时为 false
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)

// ✅ 状态机,互斥且完备
const [status, setStatus] = useState("idle")
// "idle" | "loading" | "success" | "error"

3. UI 状态

定义: 纯界面控制的状态,和业务数据无关。

例子:

  • 弹窗开关:isModalOpen
  • 展开的行:expandedId
  • 选中的 Tab:activeTab
  • 悬停的元素:hoveredId
  • 侧边栏折叠:sidebarCollapsed
  • 正在编辑的项:editingId

特点:

  • 不影响业务逻辑。
  • 通常不需要持久化。
  • 组件卸载后可以丢弃。

放置位置: 就近放置。弹窗状态放在触发弹窗的组件里,不要提到全局。

一个常见的反模式:

jsx 复制代码
// ❌ 把展开状态存成数组,每次切换要遍历
const [expandedIds, setExpandedIds] = useState([])

// ✅ 如果同时只能展开一个,用单个 id
const [expandedId, setExpandedId] = useState(null)

// ✅ 如果可以同时展开多个,用 Set
const [expandedIds, setExpandedIds] = useState(new Set())

4. 派生状态

定义: 从其他状态计算出来的数据。它不是状态,是计算。

例子:

  • filteredList = list.filter(...)
  • totalPrice = items.reduce(...)
  • fullName = firstName + lastName
  • isEmpty = list.length === 0
  • buttonDisabled = !email || !password
  • totalPages = Math.ceil(total / pageSize)
  • hasNext = page < totalPages

特点:

  • 不占状态,不消耗内存。
  • 自动保持同步,不可能不一致。
  • 可以缓存(useMemo),但不是必须。

处理原则:能派生就不存。

一个判断技巧: 如果某个值能用一行表达式从其他状态算出来,它就是派生的。

jsx 复制代码
// 一行能算出来的,都是派生
const fullName = `${firstName} ${lastName}`
const isEmpty = items.length === 0
const totalPages = Math.ceil(total / pageSize)
const canSubmit = email && password && !submitting

5. 全局状态

定义: 跨多个页面或多个无关组件共享的状态。

例子:

  • 登录用户信息:currentUser
  • 主题:theme
  • 语言:locale
  • 全局通知:toasts
  • 跨页面缓存:productsCache

特点:

  • 改动影响面大。
  • 通常需要专门的状态管理方案。
  • 容易滥用。

放置原则:不到万不得已,不要全局。

判断标准:问自己"这个状态被几个页面用?"一个页面 → 不要全局。多个页面但逻辑独立 → 考虑各自的页面级状态。多个页面且需要同步 → 全局。

五类状态对照表

类型 来源 变化频率 放置位置 典型例子
输入状态 用户 高 就近或提升 keyword、email
异步状态 服务端 中 页面级或全局 products、status
UI 状态 界面 中 就近 isModalOpen、activeTab
派生状态 计算 --- 不存,现算 filteredList、totalPrice
全局状态 跨页面 低 全局 currentUser、theme

五、状态建模五步法

拿到一个页面,按这五步做状态建模。每步都有明确的动作和输出。

第一步:列出所有候选状态

把页面上所有"会变的东西"列出来,不管是不是状态。宁可多列,不要漏。

技巧: 从三个角度找。

  • 用户能操作什么?(输入、选择、点击、拖拽)
  • 服务端能返回什么?(数据、状态、错误)
  • 界面上什么在变?(显示/隐藏、展开/折叠、选中/未选中)

第二步:剔除派生状态

对每一个候选,问"它能从别的状态算出来吗?"能,就删掉,改成计算。

技巧: 问"如果其他状态都定了,这个值是不是唯一确定?"是,就是派生的。

第三步:合并冲突状态

对剩下的,问"有没有几个状态是互斥的?"有,合并成状态机。

技巧: 找那些"最多只有一个为 true"的布尔组。

第四步:给状态分类

按五类状态给每个状态打标签,决定放置位置。

第五步:确定最小真相

最终留下的,就是最小状态集。如果两个状态总是一起变,考虑合并成一个对象。

实战演示:商品列表页

第一步:列出候选

用户操作:

  • 搜索词、筛选条件、排序方式、页码、每页条数

服务端返回:

  • 商品数据、总条数、加载中、加载成功、加载失败、错误信息

界面控制:

  • 选中的商品、展开的商品详情

算出来的(后来会发现是派生):

  • 过滤后的商品、排序后的商品、当前页商品、总页数
  • 购物车数量、购物车总价
  • 是否为空、是否显示分页器、是否有上一页、是否有下一页

第二步:剔除派生

  • ❌ 过滤后的商品 → 从 products + keyword + filters 派生
  • ❌ 排序后的商品 → 从 filtered + sortBy 派生
  • ❌ 当前页商品 → 从 sorted + page + pageSize 派生
  • ❌ 总页数 → 从 total + pageSize 派生
  • ❌ 购物车数量 → 从 cart 派生
  • ❌ 购物车总价 → 从 cart 派生
  • ❌ 是否为空 → 从 products + status 派生
  • ❌ 是否显示分页器 → 从 total + pageSize 派生
  • ❌ 是否有上一页 → 从 page 派生
  • ❌ 是否有下一页 → 从 page + totalPages 派生

第三步:合并冲突

  • 加载中、加载成功、加载失败、错误信息 → 合并为 status + error

第四步:分类

状态 类型 放置位置
keyword 输入 页面
filters 输入 页面
sortBy 输入 页面
page 输入 页面
pageSize 输入 页面
products 异步 页面
total 异步 页面
status 异步 页面
error 异步 页面
cart 全局 全局
selectedIds UI 页面
expandedId UI 页面

第五步:最小状态集

从 20+ 个候选,精简到 12 个状态。其余全部派生。

jsx 复制代码
function ProductList() {
  // 输入状态
  const [keyword, setKeyword] = useState("")
  const [filters, setFilters] = useState({})
  const [sortBy, setSortBy] = useState("default")
  const [page, setPage] = useState(1)
  const [pageSize, setPageSize] = useState(20)

  // 异步状态
  const [products, setProducts] = useState([])
  const [total, setTotal] = useState(0)
  const [status, setStatus] = useState("idle")
  const [error, setError] = useState(null)

  // UI 状态
  const [selectedIds, setSelectedIds] = useState(new Set())
  const [expandedId, setExpandedId] = useState(null)

  // 派生:不占状态
  const filtered = useMemo(() =>
    products.filter(p => matchKeyword(p, keyword) && matchFilters(p, filters)),
    [products, keyword, filters]
  )
  const sorted = useMemo(() =>
    sortProducts(filtered, sortBy),
    [filtered, sortBy]
  )
  const totalPages = Math.ceil(total / pageSize)
  const isEmpty = status === "success" && products.length === 0
  const hasPrev = page > 1
  const hasNext = page < totalPages
}

对比:原来 20+ 个状态互相同步,现在 12 个状态,其余自动派生。Bug 数量级下降。


六、状态放置:状态该放在哪

状态建对了,还要放对位置。放错位置会导致 props 层层传递、不必要的重渲染、状态同步困难。

放置原则

状态应该放在使用它的最近公共父级。

  • 只有一个组件用 → 放在该组件。
  • 兄弟组件用 → 提升到父级。
  • 跨页面用 → 全局。

三个信号:状态放错了

信号 1:Props 层层传递。

jsx 复制代码
// ❌ keyword 从 App 传到 Layout 传到 Page 传到 SearchBox
// 中间组件都不用 keyword,只是转发
function App() {
  const [keyword, setKeyword] = useState("")
  return <Layout keyword={keyword} setKeyword={setKeyword} />
}
function Layout({ keyword, setKeyword }) {
  return <Page keyword={keyword} setKeyword={setKeyword} />
}
function Page({ keyword, setKeyword }) {
  return <SearchBox keyword={keyword} setKeyword={setKeyword} />
}

说明状态放太深了,应该提升到最近的真正使用它的父级。

信号 2:状态需要同步。

jsx 复制代码
// ❌ 两个组件各持有一份相关状态,还要手动同步
function SearchBox() {
  const [keyword, setKeyword] = useState("")
  // 结果列表拿不到 keyword,需要额外机制同步
}
function ResultList() {
  const [keyword, setKeyword] = useState("")
  // 两边可能不一致
}

信号 3:组件难以复用。

jsx 复制代码
// ❌ 通用组件内部持有业务状态,无法在不同场景复用
function UserCard() {
  const [isFollowing, setIsFollowing] = useState(false)
  // 关注状态是业务状态,不该内置在通用卡片里
}

状态下放:避免不必要的全局

反过来,也有状态放太高的反模式:

jsx 复制代码
// ❌ 弹窗状态放在全局,其实只有这个页面用
function App() {
  const [isModalOpen, setIsModalOpen] = useState(false)
  return <Page isModalOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />
}

// ✅ 弹窗状态就近放在使用它的组件
function Page() {
  const [isModalOpen, setIsModalOpen] = useState(false)
  return <Modal open={isModalOpen} onClose={() => setIsModalOpen(false)} />
}

判断标准: 问自己"除了这个组件,还有谁需要这个状态?"没有,就别提升。

一个决策流程图

text 复制代码
这个状态有几个组件用?
  │
  ├─ 1 个 → 放在该组件内部
  │
  ├─ 兄弟组件 → 提升到最近公共父级
  │
  └─ 跨页面 → 考虑全局
       │
       └─ 真的跨页面吗?
            ├─ 是 → 全局
            └─ 只是同一页面不同区域 → 提升到页面级,不要全局

状态提升的完整模式

提升状态后,子组件变成受控组件:通过 props 接收值,通过回调通知变化。

jsx 复制代码
// 父组件:持有状态
function Page() {
  const [keyword, setKeyword] = useState("")
  return (
    <>
      <SearchBox value={keyword} onChange={setKeyword} />
      <ResultList keyword={keyword} />
    </>
  )
}

// 子组件:受控,不持有状态
function SearchBox({ value, onChange }) {
  return (
    <input
      value={value}
      onChange={e => onChange(e.target.value)}
    />
  )
}

function ResultList({ keyword }) {
  // 使用 keyword 渲染
}

关键: 子组件不持有状态,它是"受控"的。这保证了状态只有一个真相来源。


七、不可变更新:完整模式

框架靠引用变化检测状态更新。修改数组和对象时,必须创建新引用。

为什么必须不可变

js 复制代码
const todos = [{ id: 1, done: false }]
todos[0].done = true  // 直接改
// todos 的引用没变,框架对比时认为"没变化",跳过更新

框架的 diff 算法用引用相等做快速判断:prev === next 就认为没变。所以必须创建新引用。

数组操作完整模式

添加:

js 复制代码
// 末尾添加
setItems(prev => [...prev, newItem])

// 开头添加
setItems(prev => [newItem, ...prev])

// 指定位置插入
setItems(prev => [
  ...prev.slice(0, index),
  newItem,
  ...prev.slice(index)
])

删除:

js 复制代码
// 按 id 删除
setItems(prev => prev.filter(item => item.id !== id))

// 删除指定索引
setItems(prev => [
  ...prev.slice(0, index),
  ...prev.slice(index + 1)
])

// 删除多个
setItems(prev => prev.filter(item => !idsToRemove.has(item.id)))

修改:

js 复制代码
// 按 id 修改
setItems(prev => prev.map(item =>
  item.id === id ? { ...item, ...changes } : item
))

// 按索引修改
setItems(prev => prev.map((item, i) =>
  i === index ? { ...item, ...changes } : item
))

// 批量修改
setItems(prev => prev.map(item =>
  selectedIds.has(item.id) ? { ...item, selected: true } : item
))

排序与反转:

js 复制代码
// ❌ sort 会修改原数组
setItems(prev => prev.sort(compare))

// ✅ 先复制再排序
setItems(prev => [...prev].sort(compare))

// ✅ reverse 同理
setItems(prev => [...prev].reverse())

替换:

js 复制代码
// 替换整个数组
setItems(newArray)

// 替换指定项
setItems(prev => prev.map(item =>
  item.id === id ? newItem : item
))

对象操作完整模式

js 复制代码
// 修改单个字段
setUser(prev => ({ ...prev, name: "新名字" }))

// 修改多个字段
setUser(prev => ({ ...prev, name: "新名字", age: 30 }))

// 删除字段
setUser(prev => {
  const { password, ...rest } = prev
  return rest
})

// 嵌套对象
setUser(prev => ({
  ...prev,
  address: { ...prev.address, city: "北京" }
}))

嵌套数组完整模式

js 复制代码
// 修改嵌套数组中的某个元素
setBoard(prev => ({
  ...prev,
  columns: prev.columns.map(col =>
    col.id === columnId
      ? {
          ...col,
          tasks: col.tasks.map(task =>
            task.id === taskId ? { ...task, done: true } : task
          )
        }
      : col
  )
}))

一条通用规则

从修改点往上,每一层都要创建新引用。

js 复制代码
// 修改 board.columns[2].tasks[5].done
// 需要新建:task、tasks、column、columns、board
// 共 5 层

用 Immer 简化

手动创建新引用很繁琐,尤其是深层嵌套。Immer 让你用"可变"的写法,自动生成不可变的新对象:

js 复制代码
import { produce } from "immer"

setBoard(produce(draft => {
  draft.columns[2].tasks[5].done = true
}))

推荐在生产项目中使用 Immer。 它消除样板代码,同时保证不可变。

不可变更新的常见陷阱

陷阱 1:sort、reverse、splice 会修改原数组。

js 复制代码
// ❌
setItems(prev => prev.sort(compare))
setItems(prev => prev.reverse())
setItems(prev => { prev.splice(0, 1); return prev })

// ✅
setItems(prev => [...prev].sort(compare))
setItems(prev => [...prev].reverse())
setItems(prev => prev.slice(1))

陷阱 2:push、pop、shift、unshift 会修改原数组。

js 复制代码
// ❌
setItems(prev => { prev.push(item); return prev })

// ✅
setItems(prev => [...prev, item])

陷阱 3:嵌套对象浅拷贝不够。

js 复制代码
// ❌ 只拷贝了外层,内层还是同一个引用
setUser(prev => ({ ...prev, address: { city: "北京" } }))
// 这会丢失 address 的其他字段

// ✅ 正确
setUser(prev => ({
  ...prev,
  address: { ...prev.address, city: "北京" }
}))

陷阱 4:修改后返回同一个引用。

js 复制代码
// ❌
setItems(prev => {
  prev.push(item)
  return prev
})

// ✅
setItems(prev => [...prev, item])

八、状态冲突与状态机

冲突状态的典型表现

jsx 复制代码
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)

三个布尔,理论上 8 种组合,但只有 4 种合法(idle、loading、success、error)。非法组合的出现,是 bug 的温床。

典型 bug:

  • 请求开始:setIsLoading(true),但忘了 setIsError(false),于是出现"加载中且有错误"。
  • 请求成功:setIsSuccess(true),但忘了 setIsLoading(false),于是出现"加载中且成功"。

状态机:让非法状态无法表示

jsx 复制代码
const [status, setStatus] = useState("idle")
// "idle" | "loading" | "success" | "error"

一个枚举值,4 种取值,天然互斥。不可能出现"加载中且成功"这种状态。

状态机三要素

  1. 状态集合: 所有可能的取值。
  2. 转移规则: 从哪个状态能到哪个状态。
  3. 事件: 触发转移的动作。
text 复制代码
idle ──submit──▶ loading ──resolve──▶ success
                     │
                     └──reject──▶ error

一旦画出这个图,代码结构就清晰了。 每个事件只负责把状态从一个值改到另一个值。

实战:异步请求的状态机

jsx 复制代码
function UserProfile({ userId }) {
  const [status, setStatus] = useState("idle")
  const [user, setUser] = useState(null)
  const [error, setError] = useState(null)

  useEffect(() => {
    let cancelled = false
    setStatus("loading")

    fetchUser(userId)
      .then(data => {
        if (cancelled) return
        setUser(data)
        setStatus("success")
      })
      .catch(err => {
        if (cancelled) return
        setError(err.message)
        setStatus("error")
      })

    return () => { cancelled = true }
  }, [userId])

  if (status === "idle") return null
  if (status === "loading") return <Spinner />
  if (status === "error") return <Error message={error} />
  return <Profile user={user} />
}

对比多个布尔的写法: 不可能出现"加载中又显示用户"的 bug,因为状态只有一个。

更严格的状态机:TypeScript 联合类型

用 TypeScript 的判别联合,可以让非法状态在类型层面就无法表示:

ts 复制代码
type State =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: User }
  | { status: "error"; error: string }

这个类型定义保证:

  • success 状态必有 data。
  • error 状态必有 error。
  • 不可能出现"成功但没有数据"。
  • 不可能出现"错误但没有错误信息"。

这是状态机的最高境界:让非法状态在编译期就无法通过。

什么时候用状态机

信号: 当你发现有两个以上的布尔状态"互斥"时,就该用状态机。

场景 用状态机? 状态集合
请求加载 是 idle / loading / success / error
文件上传 是 idle / uploading / success / error
支付流程 是 idle / confirming / paying / success / error
多步骤表单 是 editing / validating / submitting / success / error
深色模式 否 独立布尔
多语言 否 独立枚举

不是所有状态都要状态机。 独立的状态(如 darkMode 和 language)保持独立即可。


九、常见误区与避坑

误区 1:把派生数据当状态

jsx 复制代码
// ❌
const [items, setItems] = useState([])
const [totalPrice, setTotalPrice] = useState(0)

function addItem(item) {
  const next = [...items, item]
  setItems(next)
  setTotalPrice(next.reduce((s, i) => s + i.price, 0))  // 手动同步
}

// ✅
const [items, setItems] = useState([])
const totalPrice = items.reduce((s, i) => s + i.price, 0)

原因: 手动同步容易漏,派生永远一致。

误区 2:用多个布尔表示互斥状态

jsx 复制代码
// ❌ 可能出现"加载中且有错误"
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)

// ✅ 状态机
const [status, setStatus] = useState("idle")

误区 3:状态放太深

jsx 复制代码
// ❌ 兄弟组件需要同一个状态,但各自持有
function SearchBox() {
  const [keyword, setKeyword] = useState("")
  // 结果列表拿不到 keyword
}

function ResultList() {
  // 无法访问 keyword
}

// ✅ 提升到父级
function Page() {
  const [keyword, setKeyword] = useState("")
  return (
    <>
      <SearchBox value={keyword} onChange={setKeyword} />
      <ResultList keyword={keyword} />
    </>
  )
}

误区 4:状态放太高

jsx 复制代码
// ❌ 弹窗状态放在全局,只有这个页面用
function App() {
  const [isModalOpen, setIsModalOpen] = useState(false)
}

// ✅ 就近
function Page() {
  const [isModalOpen, setIsModalOpen] = useState(false)
}

误区 5:直接修改状态

jsx 复制代码
// ❌
items.push(newItem)
setItems(items)

// ✅
setItems(prev => [...prev, newItem])

误区 6:用索引作为列表 key

jsx 复制代码
// ❌ 删除或排序后,key 错位,状态混乱
{items.map((item, index) => <Item key={index} />)}

// ✅ 用稳定 id
{items.map(item => <Item key={item.id} />)}

原因: 索引会变,id 不会。用索引作 key,删除中间项后,后面所有项的 key 都变了,框架会认为它们都是新项。

误区 7:用 useEffect 同步派生状态

jsx 复制代码
// ❌ 典型的反模式
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [canSubmit, setCanSubmit] = useState(false)

useEffect(() => {
  setCanSubmit(email && password)
}, [email, password])

// ✅ 直接派生
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const canSubmit = email && password

原因: 用 effect 同步派生状态,会导致多一次渲染、中间态不一致、可能死循环。派生就是派生,不该用状态和 effect 模拟。

误区 8:状态对象字段过多

jsx 复制代码
// ❌ 一个对象装所有东西,任何字段变化都要更新整个对象
const [form, setForm] = useState({
  name: "",
  email: "",
  phone: "",
  address: "",
  // ... 20 个字段
})

// ✅ 如果字段独立变化,考虑拆开
const [name, setName] = useState("")
const [email, setEmail] = useState("")

// ✅ 如果字段总是一起更新,保持对象
const [dateRange, setDateRange] = useState({ start: null, end: null })

判断标准: 字段总是一起变 → 用对象。字段独立变 → 拆开或分开更新。


十、跨框架对照

状态声明

React:

jsx 复制代码
const [count, setCount] = useState(0)

Vue 3:

vue 复制代码
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>

SwiftUI:

swift 复制代码
@State private var count = 0

Flutter:

dart 复制代码
int count = 0;
// 修改后调用 setState(() {})

Jetpack Compose:

kotlin 复制代码
var count by remember { mutableStateOf(0) }

派生状态

React:

jsx 复制代码
const filtered = items.filter(i => i.active)
// 或缓存版本
const filtered = useMemo(() => items.filter(i => i.active), [items])

Vue 3:

js 复制代码
import { computed } from 'vue'
const filtered = computed(() => items.value.filter(i => i.active))

SwiftUI:

swift 复制代码
var filtered: [Item] {
    items.filter { $0.active }
}

Flutter:

dart 复制代码
List<Item> get filtered => items.where((i) => i.active).toList();

Jetpack Compose:

kotlin 复制代码
val filtered = remember(items) { items.filter { it.active } }

不可变更新

React:

jsx 复制代码
setItems(prev => [...prev, newItem])

Vue 3:

js 复制代码
items.value = [...items.value, newItem]

SwiftUI:

swift 复制代码
items.append(newItem)

Flutter:

dart 复制代码
setState(() {
  items = [...items, newItem];
});

Jetpack Compose:

kotlin 复制代码
items = items + newItem

注意: 不同框架对"不可变"的要求程度不同。React 最严格,SwiftUI 和 Vue 的响应式系统能处理部分可变操作。但养成不可变更新的习惯,在所有框架里都是好实践。


十一、练一练:15 道多元化习题

1. 选择题

题目: 以下哪个不是状态?

A. 输入框的当前内容

B. 从商品列表算出的总价

C. 弹窗是否打开

D. 从服务端加载的用户信息

参考答案: B

解读: 总价可以从商品列表算出来,是派生数据,不是状态。A、C、D 都是不可派生的事实,是状态。这道题考察的是最核心的判断标准:能不能从其他状态算出来。


2. 判断题

题目: 只要能从其他状态算出来,就不应该单独存为状态。

参考答案: 正确。

解读: 这是最小真相原则。冗余状态需要手动同步,容易不一致。但有一个例外:如果计算代价极高(如复杂的大数据聚合),可以用 useMemo 缓存,但缓存不是状态,是性能优化。区分"状态"和"缓存"的关键是:状态有 setX,缓存没有。


3. 填空题

题目: 状态的五类分别是:、 、、、______。

参考答案: 输入状态、异步状态、UI 状态、派生状态、全局状态。

解读: 分类的目的是知道每类该放在哪里、该怎么处理。输入状态就近或提升,异步状态页面级或全局,UI 状态就近,派生状态不存,全局状态慎用。判断一个状态属于哪一类,看它的来源:用户给的、服务端给的、界面产生的、算出来的、跨页面的。


4. 代码阅读题

题目: 下面代码中有哪些状态是冗余的?请说明理由。

jsx 复制代码
const [items, setItems] = useState([])
const [count, setCount] = useState(0)
const [isEmpty, setIsEmpty] = useState(true)
const [totalPrice, setTotalPrice] = useState(0)
const [keyword, setKeyword] = useState("")

参考答案:

冗余状态:

  • count:可由 items.length 派生
  • isEmpty:可由 items.length === 0 派生
  • totalPrice:可由 items.reduce((s, i) => s + i.price, 0) 派生

最小状态:

  • items(异步状态)
  • keyword(输入状态)

解读: 5 个状态精简到 2 个。冗余状态不但多写代码,还引入不一致的可能。识别信号是"它能不能从别的状态算出来"。这道题的关键是识别:count、isEmpty、totalPrice 都唯一由 items 决定。


5. 找错题

题目: 下面代码有什么问题?如何改正?

jsx 复制代码
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)

参考答案: 三个布尔状态互斥但不完备,可能出现"加载中且成功"或"三者都为 false"的非法组合。改为状态机:

jsx 复制代码
const [status, setStatus] = useState("idle")
// "idle" | "loading" | "success" | "error"

解读: 多个布尔表示互斥状态,会导致组合爆炸和非法状态。状态机用一个枚举值,从结构上杜绝非法组合。这就是"让非法状态无法表示"原则。判断信号:有没有两个以上的布尔"最多只有一个为 true"?有,就该用状态机。


6. 状态放置题

题目: 一个搜索框组件和一个结果列表组件是兄弟关系,都需要访问 keyword。keyword 应该放在哪里?

参考答案: 放在它们的共同父级。

jsx 复制代码
function Page() {
  const [keyword, setKeyword] = useState("")
  return (
    <>
      <SearchBox value={keyword} onChange={setKeyword} />
      <ResultList keyword={keyword} />
    </>
  )
}

解读: 状态应该放在使用它的最近公共父级。兄弟组件需要共享状态,就必须提升到父级,然后通过 props 向下传、回调向上传。这是声明式 UI 单向数据流的基础。提升后,子组件变成受控组件,状态只有一个真相来源。


7. 不可变更新题

题目: 用不可变方式实现:把 items 中 id 为 3 的元素的 done 字段改为 true。

参考答案:

js 复制代码
setItems(prev => prev.map(item =>
  item.id === 3 ? { ...item, done: true } : item
))

解读: 不可变更新的关键是:修改点往上每一层都创建新引用。这里改的是数组中的某个对象,所以对象和数组都要新建。map 返回新数组,扩展运算符创建新对象。这是最常用的不可变更新模式,必须练到条件反射。


8. 嵌套更新题

题目: 用不可变方式实现:把 board.columns 中 id 为 col-1 的列的 title 改为 "已完成"。

参考答案:

js 复制代码
setBoard(prev => ({
  ...prev,
  columns: prev.columns.map(col =>
    col.id === "col-1" ? { ...col, title: "已完成" } : col
  )
}))

解读: 从修改点往上,需要新建:col、columns、board。这是不可变更新的通用规则。项目复杂时,推荐用 Immer 简化:

js 复制代码
setBoard(produce(draft => {
  draft.columns.find(c => c.id === "col-1").title = "已完成"
}))

Immer 让你用"可变"的写法,自动生成不可变的新对象,消除样板代码。


9. 状态分类题

题目: 给下列状态分类(输入、异步、UI、派生、全局):

  • currentUser
  • keyword
  • isModalOpen
  • filteredList
  • products
  • theme
  • page

参考答案:

  • currentUser → 全局状态
  • keyword → 输入状态
  • isModalOpen → UI 状态
  • filteredList → 派生状态
  • products → 异步状态
  • theme → 全局状态
  • page → 输入状态

解读: 分类的关键是看来源:用户给的、服务端给的、界面产生的、算出来的、跨页面的。分清楚才知道每类该放哪里。注意 filteredList 虽然名字像状态,但它是派生的,不该用 useState。


10. 状态机设计题

题目: 为一个"文件上传"功能设计状态机。状态有哪些?转移规则是什么?

参考答案:

状态集合:

  • idle:初始状态
  • uploading:上传中
  • success:上传成功
  • error:上传失败

转移规则:

text 复制代码
idle ──upload()──▶ uploading
uploading ──resolve──▶ success
uploading ──reject──▶ error
error ──retry()──▶ uploading
success ──reset()──▶ idle

解读: 状态机设计的关键是:列出所有可能的状态,画出状态之间的转移。一旦画出来,代码结构就清晰了。每个事件只负责把状态从一个值改到另一个值。注意 progress 是独立状态,因为它和 status 不互斥------它只在 uploading 时有意义,但是连续值。


11. 状态提升题

题目: 下面代码有什么问题?如何修改?

jsx 复制代码
function SearchBox() {
  const [keyword, setKeyword] = useState("")
  return <input value={keyword} onChange={e => setKeyword(e.target.value)} />
}

function ResultList() {
  const [keyword, setKeyword] = useState("")
  return <div>搜索:{keyword}</div>
}

参考答案: 两个组件各自持有 keyword,无法同步。应该提升到父级:

jsx 复制代码
function Page() {
  const [keyword, setKeyword] = useState("")
  return (
    <>
      <SearchBox value={keyword} onChange={setKeyword} />
      <ResultList keyword={keyword} />
    </>
  )
}

function SearchBox({ value, onChange }) {
  return <input value={value} onChange={e => onChange(e.target.value)} />
}

function ResultList({ keyword }) {
  return <div>搜索:{keyword}</div>
}

解读: 兄弟组件需要共享状态时,状态必须提升到共同父级。这样只有一个真相来源,不可能不一致。提升后,子组件变成受控组件,通过 props 接收值、通过回调通知变化。这是声明式 UI 单向数据流的标准模式。


12. 派生 vs 状态题

题目: 下面两种写法,哪种更好?为什么?

jsx 复制代码
// 写法 A
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [canSubmit, setCanSubmit] = useState(false)

useEffect(() => {
  setCanSubmit(email && password)
}, [email, password])

// 写法 B
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const canSubmit = email && password

参考答案: 写法 B 更好。

解读: canSubmit 能从 email 和 password 派生,不该作为状态。写法 A 用 useEffect 同步派生状态,是典型的反模式------多了一次渲染,多了一份需要同步的数据。写法 B 中 canSubmit 永远和 email、password 一致,不可能出错。这是状态建模中最常见的错误之一,必须牢记。


13. 不可变陷阱题

题目: 下面代码有什么问题?

js 复制代码
setItems(prev => prev.sort((a, b) => a.price - b.price))

参考答案: sort 会原地修改数组,prev 被改变,引用不变,框架可能检测不到变化。正确写法:

js 复制代码
setItems(prev => [...prev].sort((a, b) => a.price - b.price))

解读: JavaScript 的 sort、reverse、splice、push、pop、shift、unshift 都会修改原数组。不可变更新时,必须先用扩展运算符或 slice 复制,再操作。这是 JavaScript 数组 API 的常见陷阱,必须牢记。


14. 状态同步题

题目: 用户切换筛选条件时,页码应该重置为 1。应该如何实现?

参考答案:

jsx 复制代码
function handleFilterChange(newFilters) {
  setFilters(newFilters)
  setPage(1)
}

解读: 这是"状态联动"问题:一个状态变化需要触发另一个状态变化。正确做法是在事件处理器里同时更新两个状态,而不是用 useEffect 监听 filters 然后 setPage(1)。原因是:useEffect 的方案会导致多一次渲染,且逻辑更绕。能用事件直接表达的联动,不要用 effect。 这也是"渲染保持纯"原则的延伸。


15. 综合建模题

题目: 为一个"待办事项"应用建模。功能:添加、删除、切换完成、筛选(全部/未完成/已完成)、清除已完成。列出所有状态,标注类型,并写出派生状态。

参考答案:

状态清单:

状态 类型 说明
todos 异步/全局 待办数据
newText 输入 新建输入框内容
filter 输入 all / active / completed
editingId UI 正在编辑的待办 id
status 异步 idle / loading / success / error
error 异步 错误信息

派生状态:

js 复制代码
const filteredTodos = todos.filter(t => {
  if (filter === "active") return !t.completed
  if (filter === "completed") return t.completed
  return true
})

const activeCount = todos.filter(t => !t.completed).length
const completedCount = todos.length - activeCount
const hasCompleted = completedCount > 0
const allCompleted = todos.length > 0 && activeCount === 0
const canAdd = newText.trim().length > 0
const isEmpty = status === "success" && todos.length === 0

解读: 这道题综合了状态分类、派生状态、状态机。几个关键点:

  1. todos 是核心数据,其余大多派生。
  2. filter 是输入状态,它和 todos 一起派生出 filteredTodos。
  3. editingId 是 UI 状态,就近放置。
  4. status 用状态机,避免多个布尔。
  5. canAdd、isEmpty、activeCount 等全部派生,不占状态。

如果这道题你能独立完成,说明状态建模的核心技能已经掌握。


十二、本课检查清单

建模状态时,按顺序问自己:

提取状态:

  • 这个页面有哪些会变的东西?
  • 哪些是用户给的?(输入状态)
  • 哪些是服务端给的?(异步状态)
  • 哪些是界面控制?(UI 状态)
  • 哪些是跨页面共享的?(全局状态)

精简状态:

  • 这个状态能从别的状态算出来吗?能 → 删掉。
  • 有没有两个状态总是一起变?有 → 合并。
  • 有没有两个状态互斥?有 → 状态机。

放置状态:

  • 这个状态有几个组件用?
  • 一个 → 就近
  • 兄弟 → 提升到共同父级
  • 跨页面 → 全局

更新状态:

  • 修改数组/对象时,是否创建了新引用?
  • 从修改点往上,每一层是否都创建了新引用?
  • 有没有用到会修改原数组的 API?(sort、reverse、splice、push)

状态机:

  • 有没有多个布尔表示互斥状态?
  • 状态机的状态集合和转移规则清楚吗?
  • 能不能画出状态转移图?

反模式自查:

  • 有没有用 useEffect 同步派生状态?
  • 有没有用索引作为列表 key?
  • 有没有状态放太深或太高?

十三、课后作业

基础题


题目 1: 下面这个组件有 6 个状态,请精简到最少。

jsx 复制代码
function Cart() {
  const [items, setItems] = useState([])
  const [count, setCount] = useState(0)
  const [totalPrice, setTotalPrice] = useState(0)
  const [isEmpty, setIsEmpty] = useState(true)
  const [hasDiscount, setHasDiscount] = useState(false)
  const [finalPrice, setFinalPrice] = useState(0)

  // ... 各种同步逻辑
}

参考答案:

分析:

  • count → 从 items.length 派生
  • totalPrice → 从 items.reduce((s, i) => s + i.price, 0) 派生
  • isEmpty → 从 items.length === 0 派生
  • hasDiscount → 从 totalPrice >= 100 派生
  • finalPrice → 从 totalPrice 和 hasDiscount 派生

最小状态:

jsx 复制代码
function Cart() {
  const [items, setItems] = useState([])

  const count = items.length
  const totalPrice = items.reduce((s, i) => s + i.price, 0)
  const isEmpty = items.length === 0
  const hasDiscount = totalPrice >= 100
  const finalPrice = hasDiscount ? totalPrice * 0.9 : totalPrice
}

解读: 6 个状态精简到 1 个。冗余状态不但多写代码,还引入不一致的可能。这道题的关键是识别"哪些能从 items 算出来"。判断标准:如果 items 定了,这个值就唯一确定,那它就是派生的。注意 hasDiscount 和 finalPrice 是链式派生------它们依赖 totalPrice,而 totalPrice 又依赖 items。链式派生是正常的,不需要中间状态。


题目 2: 用不可变方式实现以下操作。

js 复制代码
const [todos, setTodos] = useState([
  { id: 1, text: "学声明式", done: false },
  { id: 2, text: "写代码", done: true }
])

a. 添加 { id: 3, text: "复习", done: false }

b. 删除 id 为 1 的项

c. 把 id 为 2 的项 done 改为 false

d. 清空所有已完成的项

参考答案:

js 复制代码
// a. 添加
setTodos(prev => [...prev, { id: 3, text: "复习", done: false }])

// b. 删除
setTodos(prev => prev.filter(t => t.id !== 1))

// c. 修改
setTodos(prev => prev.map(t =>
  t.id === 2 ? { ...t, done: false } : t
))

// d. 清空已完成
setTodos(prev => prev.filter(t => !t.done))

解读: 四个操作分别对应不可变更新的四种基本模式:扩展运算符添加、filter 删除、map + 扩展运算符修改、filter 条件删除。熟练这四种模式,能覆盖 90% 的数组操作场景。注意 filter 和 map 都返回新数组,所以引用自动变化。


题目 3: 判断下列状态该放在哪里。

a. 当前登录用户信息(多个页面用)

b. 一个下拉菜单是否打开(只有一个组件用)

c. 搜索框关键词(搜索框和结果列表是兄弟组件)

d. 主题(深色/浅色,全应用用)

e. 表单的临时草稿(只有表单组件用)

参考答案:

a. 全局状态 --- 跨页面共享

b. 组件内部状态 --- 就近放置

c. 提升到共同父级 --- 兄弟组件共享

d. 全局状态 --- 全应用共享

e. 组件内部状态 --- 就近放置

解读: 状态放置的核心原则是"最近公共父级"。判断顺序:先问有谁用,再决定放哪里。跨页面 → 全局;兄弟组件 → 提升到父级;只有一个组件用 → 就近。不要不必要地提升,也不要该提升却不提升。 提升过度会导致 props 层层传递,下放过度会导致状态无法共享。


进阶题


题目 4: 下面是一个文件上传组件的状态。请改写成状态机。

jsx 复制代码
const [isUploading, setIsUploading] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const [isError, setIsError] = useState(false)
const [progress, setProgress] = useState(0)

参考答案:

jsx 复制代码
const [status, setStatus] = useState("idle")
// "idle" | "uploading" | "success" | "error"
const [progress, setProgress] = useState(0)

async function upload(file) {
  setStatus("uploading")
  setProgress(0)

  try {
    await api.upload(file, p => setProgress(p))
    setStatus("success")
  } catch {
    setStatus("error")
  }
}

function reset() {
  setStatus("idle")
  setProgress(0)
}

return (
  <div>
    {status === "idle" && <button onClick={() => upload(file)}>上传</button>}
    {status === "uploading" && <p>上传中 {progress}%</p>}
    {status === "success" && (
      <>
        <p>上传成功</p>
        <button onClick={reset}>继续上传</button>
      </>
    )}
    {status === "error" && (
      <>
        <p>上传失败</p>
        <button onClick={() => upload(file)}>重试</button>
      </>
    )}
  </div>
)

解读: 三个布尔有 8 种组合,只有 4 种合法。状态机用枚举值替代,从结构上杜绝非法组合。注意 progress 保留为独立状态,因为它和 status 不互斥------它只在 uploading 时有意义,但是连续值,不是枚举。状态机和普通状态可以共存,关键是互斥的用状态机,独立的保持独立。


题目 5: 一个"多步骤表单"有 3 步。请设计状态。要求:能前进、后退、提交,提交中有加载状态,提交失败能显示错误。

参考答案:

状态设计:

jsx 复制代码
// 当前步骤(输入状态)
const [step, setStep] = useState(0) // 0 | 1 | 2

// 表单数据(输入状态)
const [formData, setFormData] = useState({
  // step 0
  name: "",
  email: "",
  // step 1
  company: "",
  role: "",
  // step 2
  plan: "free"
})

// 提交状态(异步状态,用状态机)
const [status, setStatus] = useState("editing")
// "editing" | "submitting" | "success" | "error"
const [error, setError] = useState(null)

// 派生
const isFirstStep = step === 0
const isLastStep = step === 2
const canGoNext = validateStep(step, formData)
const canSubmit = isLastStep && validateAll(formData)
const isSubmitting = status === "submitting"

事件:

js 复制代码
function next() {
  if (!canGoNext) return
  setStep(s => s + 1)
}

function prev() {
  setStep(s => s - 1)
}

async function submit() {
  if (!canSubmit) return
  setStatus("submitting")
  try {
    await api.submit(formData)
    setStatus("success")
  } catch (e) {
    setError(e.message)
    setStatus("error")
  }
}

解读: 这道题综合了五类状态。step 和 formData 是输入状态,status 是异步状态机,error 是异步状态,其余是派生。注意 status 的初始值是 editing 而不是 idle,因为它描述的是表单整体状态,编辑中是最自然的初始态。状态机设计要贴合业务语义。 这道题的关键是:step 和 formData 可以合并,但拆开更清晰------step 是导航状态,formData 是数据状态,职责不同。


题目 6: 下面是一段有问题的代码,请找出所有问题并修复。

jsx 复制代码
function ProductPage() {
  const [products, setProducts] = useState([])
  const [filteredProducts, setFilteredProducts] = useState([])
  const [keyword, setKeyword] = useState("")
  const [isLoading, setIsLoading] = useState(false)
  const [isError, setIsError] = useState(false)
  const [errorMessage, setErrorMessage] = useState("")
  const [count, setCount] = useState(0)

  useEffect(() => {
    setIsLoading(true)
    fetchProducts().then(data => {
      setProducts(data)
      setFilteredProducts(data)
      setCount(data.length)
      setIsLoading(false)
    }).catch(err => {
      setIsError(true)
      setErrorMessage(err.message)
      setIsLoading(false)
    })
  }, [])

  useEffect(() => {
    const filtered = products.filter(p => p.name.includes(keyword))
    setFilteredProducts(filtered)
    setCount(filtered.length)
  }, [keyword, products])

  return (
    <div>
      <input value={keyword} onChange={e => setKeyword(e.target.value)} />
      <p>共 {count} 条</p>
      {isLoading && <Spinner />}
      {isError && <Error message={errorMessage} />}
      {filteredProducts.map(p => <Product key={p.id} {...p} />)}
    </div>
  )
}

参考答案:

问题清单:

  1. filteredProducts 是派生的,不该作为状态。
  2. count 是派生的(filteredProducts.length),不该作为状态。
  3. isLoading、isError、errorMessage 是互斥的,应该用状态机。
  4. 用 useEffect 同步 filteredProducts 和 count,是反模式,多一次渲染。
  5. 请求没有处理竞态(虽然这里只请求一次,但模式不对)。
  6. 请求没有清理函数。

修复后:

jsx 复制代码
function ProductPage() {
  const [products, setProducts] = useState([])
  const [keyword, setKeyword] = useState("")
  const [status, setStatus] = useState("idle")
  const [error, setError] = useState(null)

  // 派生:不占状态
  const filteredProducts = products.filter(p =>
    p.name.includes(keyword)
  )
  const count = filteredProducts.length

  useEffect(() => {
    let cancelled = false
    setStatus("loading")

    fetchProducts()
      .then(data => {
        if (cancelled) return
        setProducts(data)
        setStatus("success")
      })
      .catch(err => {
        if (cancelled) return
        setError(err.message)
        setStatus("error")
      })

    return () => { cancelled = true }
  }, [])

  return (
    <div>
      <input value={keyword} onChange={e => setKeyword(e.target.value)} />
      <p>共 {count} 条</p>
      {status === "loading" && <Spinner />}
      {status === "error" && <Error message={error} />}
      {status === "success" && filteredProducts.map(p =>
        <Product key={p.id} {...p} />
      )}
    </div>
  )
}

解读: 原代码 7 个状态,修复后 4 个。关键改动:删掉派生状态、合并冲突状态、去掉同步用的 useEffect、加上请求清理。这段代码是典型的"初学者综合症":把所有东西都当状态,用 effect 同步一切。 修复后的版本状态清晰,不可能出现不一致。


思考题


题目 7: 为什么"用 useEffect 同步派生状态"是反模式?

参考答案:

因为派生状态根本不该是状态。用 useEffect 同步它,会导致:

  1. 多一次渲染。 状态变化 → 渲染 → effect 执行 → 改派生状态 → 再渲染。
  2. 中间态不一致。 第一次渲染时,派生状态还是旧值,界面可能显示错乱。
  3. 增加复杂度。 本来一行计算搞定,现在要维护状态 + effect。
  4. 可能死循环。 如果依赖数组处理不当,effect 改状态又触发 effect。

正确做法: 直接用 const derived = compute(state),不用状态,不用 effect。

解读: 判断信号:当你写 useEffect(() => setX(...), [y]) 时,先问"x 是不是能从 y 算出来?"如果是,就不该用状态 + effect,应该直接派生。这个反模式极其常见,是状态建模的头号错误。记住:effect 是给副作用用的,不是给派生用的。


题目 8: 状态提升和状态下放分别解决什么问题?什么时候用哪个?

参考答案:

状态提升解决"多个组件需要共享状态"的问题。当兄弟组件或跨层级组件需要同一份数据时,把状态提升到它们的最近公共父级。

状态下放解决"状态放太高导致不必要的复杂"的问题。当状态只被一个组件使用时,放在该组件内部,不要提升。

判断标准:

  • 有多个组件用 → 提升。
  • 只有一个组件用 → 下放(就近)。
  • 提升的代价是 props 传递,下放的代价是无法共享。

解读: 两者是一对平衡。提升过度,会导致 props 层层传递(prop drilling);下放过度,会导致状态无法共享。经验法则是:默认就近,需要共享时才提升,且只提升到最近公共父级,不要一步到位提到全局。 这是状态放置的核心原则。


题目 9: 什么是"让非法状态无法表示"?举一个例子。

参考答案:

"让非法状态无法表示"是指:通过数据结构的设计,让不可能的状态组合在类型或结构上就无法表达,而不是靠运行时检查。

例子:

jsx 复制代码
// ❌ 非法状态可表示
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)
// 可以同时为 true,非法

// ✅ 非法状态无法表示
const [status, setStatus] = useState("idle")
// 只能是 4 个值之一,天然互斥

更严格的例子(TypeScript):

ts 复制代码
type State =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: User }
  | { status: "error"; error: string }

这个类型定义保证:success 状态必有 data,error 状态必有 error。不可能出现"成功但没有数据"。

解读: 这是状态机思维的核心价值。与其在运行时检查"这个组合合法吗",不如在结构上让它不可能。 这会大幅减少 bug,因为非法状态根本无法产生。这是状态建模的最高境界。


挑战题


题目 10: 设计一个"看板(Kanban)"应用的状态模型。功能:多个列,每列多个卡片,卡片可在列间拖拽,支持编辑卡片、添加卡片、删除卡片。列出所有状态,标注类型,写出派生状态,并说明拖拽过程中的状态设计。

参考答案:

状态清单:

状态 类型 说明
board 异步 看板数据:{ columns: [{ id, title, cards: [] }] }
status 异步 idle / loading / success / error
error 异步 错误信息
draggingCardId UI 正在拖拽的卡片 id
dragOverColumnId UI 当前悬停的列 id
editingCardId UI 正在编辑的卡片 id
addingToColumnId UI 正在添加卡片的列 id
keyword 输入 搜索关键词(可选)

派生状态:

js 复制代码
const allCards = board.columns.flatMap(c => c.cards)
const cardCount = allCards.length
const filteredColumns = keyword
  ? board.columns.map(col => ({
      ...col,
      cards: col.cards.filter(c => c.title.includes(keyword))
    }))
  : board.columns
const isDragging = draggingCardId !== null
const canDrop = dragOverColumnId !== null

拖拽过程的状态设计:

拖拽有三种常见实现方式:

  1. HTML5 Drag API: draggingCardId + dragOverColumnId。
  2. 鼠标事件: 额外需要 dragOffset(拖拽位置偏移)。
  3. 第三方库(如 dnd-kit): 库内部管理,你只处理 onDragEnd 事件。

拖拽结束时的不可变更新:

js 复制代码
function handleDragEnd({ cardId, fromColumnId, toColumnId, toIndex }) {
  setBoard(prev => {
    const fromCol = prev.columns.find(c => c.id === fromColumnId)
    const card = fromCol.cards.find(c => c.id === cardId)

    return {
      ...prev,
      columns: prev.columns.map(col => {
        // 从原列移除
        if (col.id === fromColumnId) {
          return { ...col, cards: col.cards.filter(c => c.id !== cardId) }
        }
        // 插入目标列
        if (col.id === toColumnId) {
          const newCards = [...col.cards]
          newCards.splice(toIndex, 0, card)
          return { ...col, cards: newCards }
        }
        return col
      })
    }
  })
}

解读:

这道题是状态建模的综合挑战。几个关键点:

  1. board 是唯一真相。 所有卡片、列都从它派生。不要在多个状态里分散存储卡片。

  2. UI 状态和业务状态分离。 draggingCardId、dragOverColumnId 是纯界面控制,不该混入 board。

  3. 拖拽过程本身是 UI 状态。 拖拽中的卡片位置、悬停的列,都是临时的 UI 状态,拖拽结束就清除。

  4. 嵌套不可变更新。 移动卡片涉及"从原列删除 + 插入目标列",两层嵌套。从修改点往上都要新建引用。复杂场景推荐 Immer。

  5. 派生过滤。 搜索关键词只影响展示,不改 board 本身,所以用派生的 filteredColumns。

如果这道题你能独立设计,说明状态建模已经达到生产级水平。


十四、本课小结

状态的定义

状态是组件需要记住的、会随时间变化的最小事实。

三个判断标准:会变、不可派生、影响渲染。

五类状态

类型 来源 放置位置
输入状态 用户 就近或提升
异步状态 服务端 页面级或全局
UI 状态 界面 就近
派生状态 计算 不存,现算
全局状态 跨页面 全局

状态建模五步法

  1. 列出候选: 所有会变的东西。
  2. 剔除派生: 能算出来的删掉。
  3. 合并冲突: 互斥的用状态机。
  4. 分类放置: 按五类决定位置。
  5. 确定最小真相: 最终留下的就是状态。

三条铁律

  1. 能派生就不存。 减少状态数量,减少同步负担。
  2. 互斥状态用状态机。 让非法状态无法表示。
  3. 不可变更新。 从修改点往上,每层都创建新引用。

状态放置原则

状态应该放在使用它的最近公共父级。 就近优先,需要共享才提升,跨页面才全局。

一句话记住本课

状态越少,bug 越少;状态越准,代码越清。

实用价值回顾

  1. 从 20 个状态精简到 10 个。 代码量减半,bug 数量级下降。
  2. 派生数据自动同步。 不可能出现"数量和总价对不上"。
  3. 状态机杜绝非法组合。 不可能出现"加载中且成功"。
  4. 不可变更新让框架高效检测变化。 性能稳定。
  5. 状态放置原则避免 props 层层传递。 组件结构清晰。

十五、下一课预告

第 3 课 单向数据流:事件如何改变状态

本课讲了"状态是什么、放在哪",下一课讲"状态怎么流动"。核心内容:

  • Props 向下、回调向上的模式
  • 受控组件:value + onChange
  • 避免双向绑定带来的隐式同步
  • 事件处理器的职责边界
  • 父子组件通信的完整模式
  • 跨层级通信:Context 与组合

状态建模是地基,单向数据流是骨架。两者结合,才能撑起完整的声明式应用。

相关推荐
传奇开心果编程3 小时前
【React Native娓娓道来】第3课 组件组合与列表渲染:把界面拆成可复用的函数
移动开发·列表渲染·编程语言理论·开发范式·编程框架·声明式ui·组件组合
传奇开心果编程5 小时前
【声明式UI开发实用技术学与练】第6课 副作用管理:Effect 的正确用法
移动开发·编程语言理论·开发范式·编程框架·声明式ui
传奇开心果编程6 小时前
【React Native娓娓道来】第4课 副作用与数据获取:useEffect、网络请求与加载状态
移动开发·数据获取·编程语言理论·开发范式·编程框架·副作用·声明式ui
传奇开心果编程11 小时前
【React Native娓娓道来】第10课 数据持久化与本地存储:AsyncStorage、SecureStore 与文件系统
移动开发·数据持久化·本地存储·编程语言理论·开发范式·编程框架·声明式ui
传奇开心果编程13 小时前
【React Native娓娓道来】第5课 导航与多页面:用 React Navigation 组织页面流
移动开发·导航·编程语言理论·开发范式·编程框架·声明式ui·多页面
传奇开心果编程13 小时前
【SwiftUI提高练中学】第19课 推送与远程通知进阶:APNs、通信通知与专注过滤
移动开发·编程语言理论·开发范式·编程框架·声明式ui
传奇开心果编程1 天前
【SwiftUI提高练中学】第9课 安全与隐私
swiftui·移动开发·swift·编程语言理论·开发范式·编程框架·声明式ui
传奇开心果编程1 天前
【声明式UI开发实用技术学与练】第10课 用状态机建模复杂交互
移动开发·编程语言理论·开发范式·编程框架·声明式ui
传奇开心果编程1 天前
【声明式UI实用开发技术学与练】第5课 表单处理:受控与非受控
ui·移动开发·开发范式·编程框架·声明式ui