第六章练习答案
答案 06-01:用户名申请
完整 src/App.jsx:
import { useActionState, useState } from 'react'
import { useFormStatus } from 'react-dom'
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
async function apply(previous, formData) {
await wait(400)
const name = String(formData.get('username') ?? '').trim()
if (name.length < 3 || name.length > 12) {
return { ...previous, message: '', error: '用户名长度必须为 3–12' }
}
if (name.toLowerCase() === 'taken') {
return { ...previous, message: '', error: '用户名已占用' }
}
return { message: `已申请:${name}`, error: '' }
}
function Submit() {
const { pending, data } = useFormStatus()
return (
<button disabled={pending}>
{pending ? `正在提交 ${data?.get('username')}…` : '申请'}
</button>
)
}
export default function App() {
const [username, setUsername] = useState('')
const [state, action] = useActionState(apply, { message: '', error: '' })
return (
<main>
<form action={action}>
<input
name="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
/>
<Submit />
</form>
{state.error && <p role="alert">{state.error}</p>}
{state.message && <p>{state.message}</p>}
</main>
)
}断言:ab 得到长度错误;taken 得到占用错误;随后提交 Ada 只显示 已申请:Ada。输入是受控 state,所以任何结果都不会隐式清空;每条返回路径显式清除另一种旧消息。
验证 Action 的数据契约时,把上面 wait、apply 两个函数定义复制到独立的 apply-check.mjs,追加以下代码,用 node apply-check.mjs 运行。这个检查不替代浏览器中的 pending 与受控输入观察。
import assert from 'node:assert/strict'
let previous = { message: '', error: '' }
for (const [name, message, error] of [
['ab', '', '用户名长度必须为 3–12'],
['TAKEN', '', '用户名已占用'],
[' Ada ', '已申请:Ada', ''],
]) {
const data = new FormData()
data.set('username', name)
previous = await apply(previous, data)
assert.deepEqual(previous, { message, error })
}
console.log('用户名错误→恢复通过')答案 06-02:乐观删除与恢复
完整 src/App.jsx:
import { useOptimistic, useState, useTransition } from 'react'
const initial = [
{ id: 'a', title: 'Alpha' },
{ id: 'locked', title: 'Locked' },
{ id: 'c', title: 'Charlie' },
]
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
export const removeById = (items, id) => items.filter((item) => item.id !== id)
async function removeOnServer(id) {
await wait(500)
if (id === 'locked') throw new Error('item is locked')
}
export default function App() {
const [items, setItems] = useState(initial)
const [error, setError] = useState('')
const [optimisticItems, removeOptimistic] = useOptimistic(items, removeById)
const [deleting, startDelete] = useTransition()
async function removeAction(id) {
removeOptimistic(id)
setError('')
try {
await removeOnServer(id)
setItems((current) => removeById(current, id))
} catch (reason) {
setError(reason.message)
}
}
return (
<main>
{deleting && <p>删除中</p>}
{error && <p role="alert">{error}</p>}
<ul>
{optimisticItems.map((item) => (
<li key={item.id}>
{item.title}
<button disabled={deleting} onClick={() => {
startDelete(() => removeAction(item.id))
}}>删除</button>
</li>
))}
</ul>
</main>
)
}纯函数与权威状态断言:
const base = [{ id: 'a' }, { id: 'locked' }, { id: 'c' }]
const optimistic = removeById(base, 'locked')
if (optimistic.map((x) => x.id).join(',') !== 'a,c') {
throw new Error('乐观层必须立即隐藏 locked')
}
if (base.map((x) => x.id).join(',') !== 'a,locked,c') {
throw new Error('reducer 不得修改权威输入')
}
const successBase = removeById(base, 'a')
if (successBase.map((x) => x.id).join(',') !== 'locked,c') {
throw new Error('成功后权威 state 必须删除 a')
}
// 失败路径不调用 setItems,因此权威 base 保留 locked;Action 结束后它按原位置恢复。失败时没有写 items,optimistic 层结束后 React 重新以原数组渲染,所以 locked 恢复原位置;成功才更新权威 state。删除按钮用第五章已学的 useTransition 建立 Action 上下文;pending 跟踪删除工作,不从两个列表长度是否相等猜测。这个答案逐次处理删除,等待时禁用所有删除按钮;若要并发,需要额外维护每次操作和错误的身份。
答案 06-03:一个 Action 处理保存与删除
完整 src/App.jsx:
import { useActionState, useState } from 'react'
import { useFormStatus } from 'react-dom'
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
async function submitProfile(previous, formData) {
await wait(400)
const intent = formData.get('intent')
const email = String(formData.get('email') ?? '').trim()
if (intent === 'save') {
return email.includes('@')
? { message: `saved ${email}`, error: '' }
: { ...previous, message: '', error: 'invalid email' }
}
if (intent === 'delete') {
return formData.get('confirm') === 'on'
? { message: 'deleted', error: '' }
: { ...previous, message: '', error: 'confirm deletion' }
}
return { ...previous, message: '', error: 'unknown intent' }
}
function Buttons() {
const { pending } = useFormStatus()
return (
<>
<button name="intent" value="save" disabled={pending}>保存</button>
<button name="intent" value="delete" disabled={pending}>删除</button>
</>
)
}
export default function App() {
const [email, setEmail] = useState('')
const [state, action] = useActionState(
submitProfile,
{ message: '', error: '' },
)
return (
<main>
<form action={action}>
<input
name="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
/>
<label><input type="checkbox" name="confirm" />确认删除</label>
<Buttons />
</form>
{state.error && <p role="alert">{state.error}</p>}
{state.message && <p>{state.message}</p>}
</main>
)
}断言:保存 ada 返回 invalid email 且输入仍是 ada;保存 ada@example.com 返回 saved;未勾选删除返回 confirm deletion;勾选后返回 deleted。点击的 submitter 才会把对应 intent 放入 FormData。
把 wait、submitProfile 复制到独立 profile-check.mjs,追加以下数据分支检查;浏览器还需实际点击两个按钮核对 submitter。
import assert from 'node:assert/strict'
let previous = { message: '', error: '' }
for (const [fields, expected] of [
[{ intent: 'save', email: 'ada' }, { message: '', error: 'invalid email' }],
[{ intent: 'save', email: 'ada@example.com' }, { message: 'saved ada@example.com', error: '' }],
[{ intent: 'delete' }, { message: '', error: 'confirm deletion' }],
[{ intent: 'delete', confirm: 'on' }, { message: 'deleted', error: '' }],
[{ intent: 'other' }, { message: '', error: 'unknown intent' }],
]) {
const data = new FormData()
for (const [key, value] of Object.entries(fields)) data.set(key, value)
previous = await submitProfile(previous, data)
assert.deepEqual(previous, expected)
}
console.log('提交意图与错误恢复通过')这仍是客户端模拟:源码和逻辑都在浏览器,任何人都能绕过检查。真实删除必须由服务端重新鉴权、校验确认策略并保证幂等。
返回第六章导读。
章节入口核对
以下入口对应本章概览或综合练习,答案核对沿用本章各知识点的实现与日志。