从状态到 reducer:把结算流程写成可验证的转换
问题:三个布尔值可以描述多少个非法组合
isEditing、isSubmitting、isSuccess 三个布尔值能组成八种组合,但这里的结算只允许“编辑中、提交中、成功”三种互斥阶段。失败后回到编辑阶段,同时保留错误说明,因而失败是一次事件,不必再设一个与编辑互相冲突的布尔值。让一个 status 和 reducer 集中定义允许的转换,能直接排除“既在提交又已成功”等组合。
完整实验:结算状态机
import { useReducer } from 'react'
const initialState = {
status: 'editing',
address: '',
error: null,
}
export function checkoutReducer(state, action) {
switch (action.type) {
case 'address_changed':
if (state.status !== 'editing') return state
return { ...state, address: action.value, error: null }
case 'submitted':
if (state.status !== 'editing') return state
if (!state.address.trim()) {
return { ...state, error: '请填写地址' }
}
return { ...state, status: 'submitting', error: null }
case 'resolved':
if (state.status !== 'submitting') return state
return { ...state, status: 'success' }
case 'rejected':
if (state.status !== 'submitting') return state
return { ...state, status: 'editing', error: action.message }
case 'reset':
return initialState
default:
throw new Error(`未知 action:${action.type}`)
}
}
export default function App() {
const [state, dispatch] = useReducer(checkoutReducer, initialState)
if (state.status === 'success') {
return (
<main>
<h1>提交成功</h1>
<button onClick={() => dispatch({ type: 'reset' })}>再填一单</button>
</main>
)
}
return (
<main>
<h1>结算</h1>
<label>
地址
<input
value={state.address}
disabled={state.status === 'submitting'}
onChange={event => dispatch({
type: 'address_changed',
value: event.target.value,
})}
/>
</label>
{state.error && <p role="alert">{state.error}</p>}
<button
disabled={state.status !== 'editing'}
onClick={() => dispatch({ type: 'submitted' })}
>
提交
</button>
{state.status === 'submitting' && (
<section>
<p>模拟服务端结果:</p>
<button onClick={() => dispatch({ type: 'resolved' })}>成功</button>
<button onClick={() => dispatch({
type: 'rejected',
message: '库存变化,请重试',
})}>失败</button>
</section>
)}
</main>
)
}按钮模拟外部结果,让本篇先只研究转换;真正异步请求与 Effect 在后续章节处理。
先留空提交,页面仍可编辑并显示“请填写地址”。填入地址后提交,输入与提交按钮都禁用,出现模拟结果按钮;点失败,地址保留、编辑恢复、错误变为库存提示;再次提交后点成功,页面进入成功视图。这一遍操作覆盖了校验、等待、恢复与完成,而不是只验证“点一下能变文字”。
action 记录发生了什么
事件处理器不再拼下一整个 state,而是 dispatch 描述性 action:
用户输入 → { type: 'address_changed', value }
用户提交 → { type: 'submitted' }
服务成功 → { type: 'resolved' }React 把当前 state 和 action 传给 reducer,reducer 返回下一 state,再触发下一次渲染。与 setter 一样,dispatch 不会改写当前处理器里的 state。
reducer 必须纯净:不发请求、不读取随机数、不修改输入 state。这样同一个输入就能在组件外直接验证,也能根据 action 日志复现错误。
转换守卫消除非法状态
resolved 只有在 submitting 时才生效;重复成功事件原样返回当前 state。address_changed 在提交期间也被拒绝。守卫表达的不是 UI 是否恰好禁用了按钮,而是领域转换本身允许什么。
未知 action 直接抛错,能暴露拼写和遗漏。对“已知 action 但当前状态不接受”的策略则取决于契约:本例原样返回;安全敏感流程也可以抛错并记录。
注意 submitted 的两层判断不能合并:非编辑阶段收到重复提交,应完全忽略,不能凭空加一个“请填写地址”的错误;只有正在编辑而地址为空,才应返回带校验错误的新 state。按钮禁用负责引导操作,reducer 守卫保证代码直接派发 action 时也遵守规则。
可以把下面代码放在同一 App.jsx 中、checkoutReducer 定义之后,用纯函数断言检查 UI 不容易点出的时序:
function check(condition, message) {
if (!condition) throw new Error(message)
}
const blank = checkoutReducer(initialState, { type: 'submitted' })
check(blank.status === 'editing' && blank.error === '请填写地址', '空地址校验')
const filled = checkoutReducer(blank, { type: 'address_changed', value: '一号路' })
const waiting = checkoutReducer(filled, { type: 'submitted' })
check(waiting.status === 'submitting' && waiting.error === null, '合法提交')
check(checkoutReducer(waiting, { type: 'submitted' }) === waiting, '重复提交不修改任何字段')
const failed = checkoutReducer(waiting, { type: 'rejected', message: '库存变化' })
check(failed.status === 'editing' && failed.address === '一号路', '失败恢复编辑并保留地址')
const done = checkoutReducer(waiting, { type: 'resolved' })
check(checkoutReducer(done, { type: 'submitted' }) === done, '完成后不能再提交')
check(initialState.address === '' && initialState.error === null, '初始对象未被修改')这里用 === 检查非法转换返回原对象;只检查 status 相同,会漏掉“阶段没变却写入错误”的缺陷。
条件变化:何时仍用 useState
若组件只有一个彼此独立的开关,useState(false) 比 reducer 更清楚。Reducer 的收益出现在:多个字段共同变化、状态转换多、事件日志有调试价值,或想在组件外独立测试逻辑时。它不自动减少渲染,也不会让 state 变成全局。
边界
- action 命名优先描述事件,如
submitted,而不是 setter 指令set_status;这样 UI 细节改变时转换语义仍稳定。 - reducer 返回原对象表示没有变化;若修改旧对象后仍返回它,React 可能按
Object.is忽略更新,而且旧快照已被污染。 useReducer的初始化器适合昂贵或需要参数化的初始计算;不要在 reducer 中偷偷初始化外部资源。- 状态机不必引入库;一个互斥
status加清楚守卫已能排除许多布尔组合。
练习:测验流程 reducer
实现完整 App.jsx 与纯函数 quizReducer。
契约:
- state 为
{ status: 'answering' | 'submitted' | 'finished', answer, attempts }; answer_changed只在 answering 生效;空答案不能 submitted;每次合法 submit 令 attempts+1;retry只能从 submitted 回到 answering,并清空 answer;finish只能从 submitted 到 finished;- 未知 action 抛错,非法时序原样返回;模块加载时用抛错断言覆盖正常转换、非法转换与输入未修改。
完成后对照答案 01。