陌上人如玉
公子世无双

2.15 React 19 新 Hooks 与 Actions

2.15 React 19 新 Hooks 与 Actions

一句话:React 19 围绕「表单与异步」补了一组 Hooks——use(在渲染期读 Promise / Context)、useActionState(管理提交结果与 pending)、useFormStatus(子组件读提交态)、useOptimistic(自动回滚的乐观更新);它们与 Server Actions 一起,把「提交 → loading → 错误 → 乐观更新 → 刷新」这条链路标准化了。

一、总览:新增了什么、各自解决什么

Hook / 能力 解决的问题 关键限制
use(promise \| context) 在渲染期读取「未就绪的值」(挂起到最近的 Suspense) 必须在渲染期调用;Promise 必须稳定;不能放 try/catch
useActionState(action, initial, permalink?) 把「Action 的返回值」与「pending 状态」交给 React 管 只对 Action(表单提交 / transition 内的异步函数)有意义
useFormStatus() 子组件读取「所属 <form> 的提交状态」 必须在 <form> 的子组件里调用(同级读不到)
useOptimistic(state, updateFn) 乐观更新(失败自动回滚) 更新函数必须在 Action / transition 中调用
<form action={fn}> 提交时自动调 fn(formData),自动 preventDefault + 重置 fn 必须是 Action(普通函数或 Server Action)
useFormState → useActionState v19 改名(旧名仍可导入但已废弃) 迁移时改 import 名即可
Server Actions('use server') 表单可直接调用服务端函数 它是公开 HTTP 端点,必须自己鉴权 + 校验
⭐ 一句话抓住这组 API 的分工:

  谁提交?      → <form action={fn}>(或 startTransition 包一个异步函数)
  提交中怎么显示?→ useFormStatus(子组件)/ useActionState 的 isPending(本组件)
  提交结果放哪?  → useActionState 的 state(返回值)
  立刻反馈怎么办?→ useOptimistic(乐观更新,失败自动回滚)
  服务端数据怎么读?→ use(promise) 或 Server Component 里直接 await

⚠️ 注意:这些 Hook 全部围绕「Action」这一个概念。
   「Action」= 「一个可以是 async 的函数,由 React 负责调度与状态管理」。

二、use:在渲染期读取 Promise 与 Context

2.1 读 Promise(配合 Suspense)

import { use, Suspense } from 'react';

function Comments({ commentsPromise }: { commentsPromise: Promise<Comment[]> }) {
  const comments = use(commentsPromise);          // ⭐ 未就绪时「挂起」到最近的 Suspense
  return (
    <ul>
      {comments.map((c) => <li key={c.id}>{c.text}</li>)}
    </ul>
  );
}

function Page() {
  // ⚠️ 关键:Promise 必须「稳定」——不能在渲染中创建
  const [commentsPromise] = useState(() => fetchComments());   // ✅ 只创建一次

  return (
    <Suspense fallback={<CommentsSkeleton />}>
      <Comments commentsPromise={commentsPromise} />
    </Suspense>
  );
}
⭐⭐ 关于「Promise 必须稳定」的完整解释(这是最容易踩的坑):

❌ 在渲染中创建 Promise:
   const comments = use(fetchComments());
   → 每次渲染都会「新建一个未完成的 Promise」
   → React 每次渲染都会「挂起」→ 【无限挂起】(页面永远显示 fallback)

✅ 三种让 Promise 稳定的方式:
① 由「上层/父组件」创建后作为 prop 传下来(React 官方例子的做法)
② 用 useState 惰性初始化:const [p] = useState(() => fetchComments())
③ 由「缓存」提供:cache() / TanStack Query / Server Component 预取

✅ 更推荐的第 ④ 种(Next.js App Router 的官方写法):
   在 Server Component 里「先启动请求、把 Promise 传下去」:
     const commentsPromise = fetchComments();     // 服务端启动,不 await
     return <Comments commentsPromise={commentsPromise} />;   // 传给客户端组件
   → Promise 在服务端创建一次,客户端组件用 use() 消费

2.2 读 Context(与 useContext 的关键差异)

import { use, createContext } from 'react';

const ThemeContext = createContext<'light' | 'dark'>('light');

// ✅ use 可以在「条件 / 循环」里调用(useContext 不行)
function Item({ items }: { items: Item[] }) {
  return (
    <ul>
      {items.map((item) => {
        const theme = use(ThemeContext);        // ⭐ 在循环里读 Context —— 合法!
        return <li key={item.id} className={theme}>{item.name}</li>;
      })}
    </ul>
  );
}

function Panel({ showTheme }: { showTheme: boolean }) {
  if (showTheme) {
    const theme = use(ThemeContext);            // ⭐ 在条件里读 —— 合法!
    return <div>{theme}</div>;
  }
  return <div>无主题</div>;
}
维度 useContext(Ctx) use(Ctx)
能否在条件 / 循环里调用 ❌(Hook 规则) ✅
能否在普通函数里调用 ❌ ⚠️ 可以,但仍必须发生在渲染期间
能否读 Promise ❌ ✅
订阅粒度 整个 value 同样是整个 value(没有变细)
是否废弃 ❌ 未废弃 —
⚠️ `use(Ctx)` 并没有改变 Context 的「订阅粒度」——
   它只是「调用位置更自由」。
   「value 一变,所有消费者都重渲染」这条规律依然成立(见 3.4 / 12.8)。

⚠️ `use` 仍然**只能在渲染期间调用**:
   ❌ 事件处理函数里
   ❌ useEffect 里
   ❌ 普通工具函数里(除非它就是渲染过程的一部分)

2.3 use 的三条硬性约束

// ① ❌ 不能在 try/catch 里调用
function Bad() {
  try {
    const data = use(promise);        // ❌ use 会「抛出 Promise」来实现挂起,
  } catch {                           //    try/catch 会把它捕获 → Suspense 失效
    return <Error />;
  }
}

// ✅ 正确做法:把错误交给 ErrorBoundary
<ErrorBoundary fallback={<ErrorView />}>
  <Suspense fallback={<Skeleton />}>
    <Bad />                           {/* use 的行为交给这两个边界 */}
  </Suspense>
</ErrorBoundary>
// ② ❌ 不能在组件外部调用
const data = use(promise);            // ❌ 不在组件/Hook 里

// ③ ✅ 可以在「渲染期间的任何位置」调用(含条件、循环、自定义 Hook)
function useTheme() {
  return use(ThemeContext);           // ✅ 自定义 Hook 里也合法
}
⭐ 为什么「不能放 try/catch」?(面试会追问)
   因为 `use` 的挂起机制就是「抛出 Promise」——
   React 在渲染时捕获这个 Promise,并去「最近的 Suspense」渲染 fallback。
   如果外面包了 try/catch,Promise 会被你自己捕获,
   React 就看不到它 → 不会挂起 → 你会拿到一个 Promise 对象而不是数据,
   于是「渲染出 [object Promise]」或直接报错。

⭐ 那「错误」怎么办?
   use 只负责「等待」,不负责「错误」——
   Promise reject 时的错误会被【抛出】并由 【ErrorBoundary】接管。
   所以标准组合依然是「ErrorBoundary 在外、Suspense 在内」(见 8.4)。

2.4 服务端组件里不需要 use

// ✅ Server Component 可以直接 await(不需要 use、不需要 Suspense)
export default async function Page() {
  const comments = await fetchComments();      // ⭐ 直接 await
  return <ul>{comments.map((c) => <li key={c.id}>{c.text}</li>)}</ul>;
}

// ⚠️ 但这样是「串行」的:必须等 fetchComments 完成才开始渲染
// ✅ 想「并行 + 流式」就用 Suspense 包住「慢的那块」:
export default function Page() {
  return (
    <>
      <Header />
      <Suspense fallback={<CommentsSkeleton />}>
        <Comments />                           {/* Comments 自己 async */}
      </Suspense>
    </>
  );
}

async function Comments() {
  const comments = await fetchComments();      // 慢
  return <CommentList comments={comments} />;
}
场景 怎么写
Server Component 取数据 直接 await(不需要 use)
Client Component 消费「服务端启动的 Promise」 use(promise)(配合 Suspense)
Client Component 读 Context use(Ctx) 或 useContext(Ctx)
数据获取(客户端发起) TanStack Query 更合适(缓存/重试/失效,见 7.4)

三、useActionState:管理提交结果与 pending

3.1 签名

const [state, formAction, isPending] = useActionState(actionFn, initialState, permalink?);
返回值 含义
state actionFn 的返回值(初始为 initialState)
formAction 可直接传给 <form action={formAction}> 或 <button formAction={formAction}>
isPending 是否有「正在执行的 Action」
// actionFn 的签名:接收「上一次的 state」与「FormData」
type ActionFn<S> = (prevState: S, formData: FormData) => S | Promise<S>;

3.2 基础用法

import { useActionState } from 'react';

interface SignupState {
  error?: string;
  fieldErrors?: Record<string, string>;
  success?: boolean;
}

async function signupAction(prevState: SignupState, formData: FormData): Promise<SignupState> {
  const email = String(formData.get('email') ?? '');
  const password = String(formData.get('password') ?? '');

  // ⭐ 服务端也要校验(不能只信前端)
  const fieldErrors: Record<string, string> = {};
  if (!email) fieldErrors.email = '请输入邮箱';
  if (password.length < 8) fieldErrors.password = '密码至少 8 位';
  if (Object.keys(fieldErrors).length) return { ...prevState, fieldErrors };

  try {
    await api.signup({ email, password });
    return { success: true };
  } catch (err) {
    return { ...prevState, error: err instanceof Error ? err.message : '注册失败' };
  }
}

function SignupForm() {
  const [state, formAction, isPending] = useActionState(signupAction, {});

  if (state.success) return <p>注册成功,请查收验证邮件</p>;

  return (
    <form action={formAction}>
      <label htmlFor="email">邮箱</label>
      <input id="email" name="email" type="email" required
             aria-invalid={!!state.fieldErrors?.email}
             aria-describedby="email-error" />
      {state.fieldErrors?.email && <p id="email-error" role="alert">{state.fieldErrors.email}</p>}

      <label htmlFor="password">密码</label>
      <input id="password" name="password" type="password" required
             aria-invalid={!!state.fieldErrors?.password} />
      {state.fieldErrors?.password && <p role="alert">{state.fieldErrors.password}</p>}

      <button disabled={isPending}>{isPending ? '提交中…' : '注册'}</button>

      {state.error && <p role="alert">{state.error}</p>}
    </form>
  );
}
⭐ 它相比「手写 useState + onSubmit」省掉了什么?

手写版(React 18 之前):
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');
  const [fieldErrors, setFieldErrors] = useState({});
  const handleSubmit = async (e) => {
    e.preventDefault();                       // ① 手动阻止默认
    setLoading(true);
    const fd = new FormData(e.currentTarget);  // ② 手动收集
    try { ... } catch { ... }
    finally { setLoading(false); }             // ③ 手动管理 loading
  };

useActionState 版:
  · ①② 由 React 处理(写在 actionFn 里,直接拿 FormData)
  · ③ 由 isPending 提供
  · 结果状态由 state 统一管理(含「上一次的返回值」)
  · ⭐ 还有一点:Action 结束后 React 会【自动重置非受控表单】

3.3 permalink:渐进增强

// 第三个参数 permalink:当「JavaScript 还没加载完」时,
// 表单会以【原生方式】提交到这个 URL(由服务端处理)
const [state, formAction, isPending] = useActionState(signupAction, {}, '/signup');

// ⭐ 场景:用户网络慢,JS 还没到就点了提交 —— 依然能工作(不用等 hydration)
// ⚠️ 它需要「服务端也实现了同样的提交逻辑」(渐进增强)

3.4 与「多个表单 / 多按钮」的配合

// ① 一个页面里多个独立表单 → 每个表单一个 useActionState
function Page() {
  return (
    <>
      <LoginForm />        {/* 各自持有自己的 useActionState */}
      <SignupForm />
    </>
  );
}

// ② 同一个表单「多个提交按钮」→ 用 formAction 覆盖
function EditPost({ save, publish }: { save: ActionFn; publish: ActionFn }) {
  const [saveState, saveAction] = useActionState(save, {});
  const [publishState, publishAction] = useActionState(publish, {});

  return (
    <form action={saveAction}>
      <textarea name="content" />
      <button type="submit">保存草稿</button>
      <button type="submit" formAction={publishAction}>发布</button>   {/* ⭐ 覆盖 form 的 action */}
    </form>
  );
}

// ③ 用「隐藏字段」区分「点了哪个按钮」
function Form() {
  const [state, formAction] = useActionState(async (prev, fd) => {
    const intent = fd.get('intent');          // 'save' | 'delete'
    return intent === 'delete' ? await remove(fd) : await save(fd);
  }, {});

  return (
    <form action={formAction}>
      <input name="title" />
      <button type="submit" name="intent" value="save">保存</button>
      <button type="submit" name="intent" value="delete">删除</button>
    </form>
  );
}

四、useFormStatus:子组件读取提交状态

4.1 签名与返回值

import { useFormStatus } from 'react-dom';      // ⚠️ 注意:来自 react-dom,不是 react

const { pending, data, method, action } = useFormStatus();
字段 类型 含义
pending boolean 所属表单是否正在提交
data FormData \| null 正在提交的数据(可用于「显示提交中显示什么」)
method 'get' \| 'post' 提交方法
action string \| function \| null 提交目标

4.2 核心约束:必须在 <form> 的子组件里

// ❌ 错误:和 form 在同一个组件里
function BadForm() {
  const { pending } = useFormStatus();       // ⚠️ 读到的是「外层的 form」(通常是空/undefined)
  return (
    <form action={myAction}>
      <button disabled={pending}>提交</button>
    </form>
  );
}
// ✅ 正确:抽成子组件
function SubmitButton({ children }: { children: React.ReactNode }) {
  const { pending } = useFormStatus();       // ⭐ 读到「最近的 form」的状态
  return <button type="submit" disabled={pending}>{pending ? '提交中…' : children}</button>;
}

function Form() {
  return (
    <form action={myAction}>
      <input name="title" />
      <SubmitButton>保存</SubmitButton>       {/* ✅ 在 form 内部 */}
    </form>
  );
}
⭐ 为什么「必须在子组件里」?
   因为 React 是通过「**表单状态 Context**」把 pending 传下去的——
   这个 Provider 由 `<form>` 元素内部提供。
   所以「和 form 同级的组件」处在 Provider 之外,读不到状态。

⚠️ 由此带来两个实践结论:
① 需要 pending 的按钮/输入框,都要抽成「form 的子组件」
② 这也让 `<SubmitButton>` 成为可复用组件(贴哪个表单都行)

4.3 能做的不止「禁用按钮」

// ① 提交中禁用整个表单的输入(避免中途修改)
function FormFields() {
  const { pending } = useFormStatus();
  return (
    <fieldset disabled={pending}>        {/* ⭐ fieldset 的 disabled 会连坐所有子控件 */}
      <input name="title" />
      <textarea name="content" />
    </fieldset>
  );
}

// ② 读取「正在提交的数据」(做「乐观显示」或「按内容显示不同提示」)
function OptimisticPreview() {
  const { pending, data } = useFormStatus();
  const title = data?.get('title');
  return pending && title ? <p>正在发布「{title}」…</p> : null;
}

// ③ 根据「提交方法」显示不同文案
function SubmitLabel() {
  const { pending, method } = useFormStatus();
  if (!pending) return <>保存</>;
  return <>{method === 'get' ? '查询中…' : '保存中…'}</>;
}

4.4 嵌套表单 / 多个表单

function App() {
  return (
    <form action={outerAction}>
      <SubmitButton />                  {/* 读「外层 form」的状态 */}
      <form action={innerAction}>       {/* ⚠️ HTML 不允许嵌套 form,这里只作说明 */}
        <SubmitButton />                {/* 读「最近的(内层)form」的状态 */}
      </form>
    </form>
  );
}
// ⭐ 规则与 Context 一致:读「最近的祖先 form」
// ⚠️ 但 HTML 规范不允许 <form> 嵌套,所以实际场景里应改用「并列的两个 form」

五、useOptimistic:自动回滚的乐观更新

5.1 语义:它是「叠加」,不是「状态」

const [optimisticState, addOptimistic] = useOptimistic(state, updateFn);

// state        —— 「真实数据」(通常来自服务端/父组件)
// updateFn     —— (currentState, optimisticValue) => 新的乐观状态
// optimisticState —— 「真实数据 + 尚未落地的乐观修改」(临时)
// addOptimistic   —— 添加一次乐观更新(必须在 Action / transition 中调用)
⭐⭐ 最关键的理解:它没有「回滚代码」

   useOptimistic 的乐观值【生命周期 = 一个 Action】:
     ① Action 开始 → addOptimistic(v) 把 v「叠加」到 state 上 → UI 立刻变
     ② Action 结束(成功 或 失败)→ 乐观叠加【被丢弃】→ UI 回到 state 的渲染结果
     ③ 如果 Action 成功且服务端数据已更新(如 revalidate),UI 显示新数据
        如果 Action 失败,UI 自动回到「原样」—— 这就是「自动回滚」

   ✅ 所以它不需要你写「previousValue 保存 + onError 回滚」那套
   ⚠️ 但也意味着「你不能控制回滚时机」(Action 结束就回滚)

5.2 基础用法:评论列表

'use client';
import { useOptimistic, useRef } from 'react';

interface Comment {
  id: string;
  text: string;
  pending?: boolean;
}

export function CommentSection({
  comments,
  addComment,
}: {
  comments: Comment[];
  addComment: (text: string) => Promise<void>;
}) {
  const formRef = useRef<HTMLFormElement>(null);

  const [optimisticComments, addOptimisticComment] = useOptimistic(
    comments,
    (current: Comment[], newText: string) => [
      ...current,
      { id: `temp-${crypto.randomUUID()}`, text: newText, pending: true },   // ⭐ 临时项
    ]
  );

  async function formAction(formData: FormData) {
    const text = String(formData.get('text') ?? '').trim();
    if (!text) return;

    addOptimisticComment(text);        // ⭐ 立刻显示(必须在 Action 中调用)
    formRef.current?.reset();          // 手动清空输入框

    await addComment(text);            // 真实提交(失败则乐观值自动丢弃)
  }

  return (
    <div>
      <ul>
        {optimisticComments.map((c) => (
          <li key={c.id} style={{ opacity: c.pending ? 0.5 : 1 }}>   {/* ⭐ 临时项半透明 */}
            {c.text}
          </li>
        ))}
      </ul>

      <form ref={formRef} action={formAction}>
        <input name="text" placeholder="说点什么…" />
        <button type="submit">发送</button>
      </form>
    </div>
  );
}

5.3 四种常见形态(覆盖增 / 删 / 改 / 赞)

// ① 【新增】:追加一个带临时 id 的项
const [optimistic, addOptimistic] = useOptimistic(items, (cur, text: string) => [
  ...cur,
  { id: `temp-${crypto.randomUUID()}`, text, pending: true },
]);

// ② 【删除】:把目标项「标记为删除中」或直接过滤掉
const [optimistic, markDeleting] = useOptimistic(items, (cur, id: string) =>
  cur.map((it) => (it.id === id ? { ...it, deleting: true } : it))     // ⭐ 标记而不是移除(体验更好)
);
// 或直接移除(更「乐观」但失败时会有「跳回来」的突兀感):
// const [optimistic, remove] = useOptimistic(items, (cur, id: string) => cur.filter((it) => it.id !== id));

// ③ 【修改】:就地替换某一项
const [optimistic, patch] = useOptimistic(items, (cur, p: { id: string; text: string }) =>
  cur.map((it) => (it.id === p.id ? { ...it, ...p, pending: true } : it))
);

// ④ 【点赞】:基于「当前乐观值」计算(保证连续点击也能正确累加)
const [optimistic, toggleLike] = useOptimistic(post, (cur, _: void) => ({
  ...cur,
  liked: !cur.liked,
  likeCount: cur.likeCount + (cur.liked ? -1 : 1),
}));
// ⚠️ 注意:updateFn 的 current 是「真实数据」,不是「上一次乐观值」
//    → 连续多次 addOptimistic 时,每次都基于「同一个真实数据」计算
//    → 想要「叠加」效果,必须让 updateFn 接收「上一次乐观结果」——
//       而实际做法是「把上一次的乐观结果也算进 current」(见下方「多次叠加」说明)
⭐⭐ 「多次叠加」的正确理解(这是最容易搞错的地方)

React 文档里的 updateFn 签名是 (currentState, optimisticValue) => ...
其中 currentState 是「**当前的真实状态**」(不是上一次乐观值)。

那「连续两次 addOptimistic」会怎样?
   React 会把「多次 add」合成「一串 reducer 应用」——
   实际效果是「第 2 次看到的 current 已经包含第 1 次的乐观结果」。
   (这与 useReducer 的「队列依次应用」语义一致)

⚠️ 所以:
   · 如果你写的是「追加」,连续两次会追加两项(正确)
   · 如果你写的是「替换整个对象」,后一次会覆盖前一次(符合直觉)
   · 不要写「依赖外部变量」的 updateFn(那会破坏「多次叠加」的正确性)

5.4 三条硬性约束

// ① ❌ 不能在事件处理里直接调用 addOptimistic(必须包在 Action / transition 里)
function Bad() {
  const [optimistic, addOptimistic] = useOptimistic(list, reducer);

  return <button onClick={() => addOptimistic('x')}>添加</button>;
  // ⚠️ 报错/警告:An optimistic state update occurred outside a transition or action
}

// ✅ 方式一:包在 form action 里(推荐)
<form action={async (fd) => { addOptimistic(...); await submit(fd); }}>

// ✅ 方式二:包在 transition 里
const [isPending, startTransition] = useTransition();
<button onClick={() => startTransition(async () => { addOptimistic('x'); await save(); })}>
// ② ⚠️ 乐观状态是「临时的」——不要拿它做「持久计算」
const [optimistic, addOptimistic] = useOptimistic(list, reducer);
const total = optimistic.length;                    // ✅ 用于显示
useEffect(() => { saveTotal(total); }, [total]);    // ❌ 不要把它同步出去(会反复触发)
localStorage.setItem('items', JSON.stringify(optimistic));   // ❌ 不要持久化乐观值

// ③ ⚠️ 临时项的 key 必须唯一且稳定(用 crypto.randomUUID() 生成临时 id)
const tempId = `temp-${crypto.randomUUID()}`;       // ✅
key={Math.random()}                                 // ❌ 每次渲染都变 → 整列重建
维度 useOptimistic(React 19) TanStack Query onMutate(见 7.6)
回滚 自动(Action 结束即丢弃) 手动(保存 previous + onError 回滚)
控制力 低(不能控制回滚时机) 高(可精细控制缓存与失效)
与谁配合 Actions(表单/transition) Query 缓存
适用 表单类提交(评论、点赞、勾选) 任何需要「操作缓存」的场景
是否需要数据层 否 是(TanStack Query)

六、把五个 Hook 串起来:一个完整的评论区

// ============ app/comments/actions.ts(服务端) ============
'use server';

import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';
import { getCurrentUser } from '@/lib/auth';

export async function addComment(formData: FormData) {
  // ⭐⭐ Server Action 是公开端点:必须鉴权 + 校验
  const user = await getCurrentUser();
  if (!user) throw new Error('请先登录');

  const text = String(formData.get('text') ?? '').trim();
  const postId = String(formData.get('postId') ?? '');
  if (!text || text.length > 500) throw new Error('内容不合法');

  await db.comment.create({ data: { text, postId, userId: user.id } });

  revalidatePath(`/posts/${postId}`);          // ⭐ 让页面缓存失效
}
// ============ app/comments/comment-section.tsx(客户端) ============
'use client';

import { useActionState, useOptimistic, useRef } from 'react';
import { useFormStatus } from 'react-dom';
import { addComment } from './actions';

interface Comment { id: string; text: string; author: string; pending?: boolean }

function SubmitButton() {
  const { pending } = useFormStatus();                     // ⭐ 子组件读提交态
  return <button type="submit" disabled={pending}>{pending ? '发送中…' : '发送'}</button>;
}

export function CommentSection({ postId, comments }: { postId: string; comments: Comment[] }) {
  const formRef = useRef<HTMLFormElement>(null);

  // ① 乐观更新:把「正在提交的评论」立刻显示出来
  const [optimisticComments, addOptimisticComment] = useOptimistic(
    comments,
    (current: Comment[], text: string) => [
      ...current,
      { id: `temp-${crypto.randomUUID()}`, text, author: '我', pending: true },
    ]
  );

  // ② 结果 + pending:把错误信息展示给用户
  const [state, formAction] = useActionState(
    async (_prev: { error?: string }, formData: FormData) => {
      const text = String(formData.get('text') ?? '').trim();
      if (!text) return { error: '请输入内容' };

      addOptimisticComment(text);                          // ⭐ 必须在 Action 内
      formRef.current?.reset();

      try {
        await addComment(formData);
        return {};                                        // 成功:清空错误
      } catch (err) {
        return { error: err instanceof Error ? err.message : '发送失败' };
      }
    },
    {}
  );

  return (
    <section>
      <ul>
        {optimisticComments.map((c) => (
          <li key={c.id} style={{ opacity: c.pending ? 0.5 : 1 }}>
            <strong>{c.author}</strong>:{c.text}
            {c.pending && <span aria-label="发送中"> ···</span>}
          </li>
        ))}
      </ul>

      <form ref={formRef} action={formAction}>
        <input type="hidden" name="postId" value={postId} />
        <textarea name="text" maxLength={500} placeholder="说点什么…" aria-label="评论内容" />
        <SubmitButton />
        {state.error && <p role="alert">{state.error}</p>}
      </form>
    </section>
  );
}
⭐ 这个例子里,四个 Hook 各自负责什么:

  <form action={formAction}>  → 提交(React 自动 preventDefault + 表单重置)
  useActionState              → 统一管理「错误信息」这个返回值
  useFormStatus               → 让「发送按钮」自己知道该不该禁用(不用把 pending 往下传)
  useOptimistic               → 立刻显示「我的评论」(失败自动消失)

⭐ 对比「React 18 手写版」省掉的代码:
  ❌ const [loading, setLoading] = useState(false)
  ❌ const [error, setError] = useState('')
  ❌ const [list, setList] = useState(comments) + 手动回滚逻辑
  ❌ e.preventDefault() + new FormData(e.currentTarget)
  ❌ formRef.current.reset()
  ❌ 把 loading 一路传进 SubmitButton

七、Server Actions:与这些 Hook 的配合

// ① 定义(服务端文件)
'use server';
export async function updateProfile(formData: FormData) { /* ... */ }

// ② 三种调用方式
// 方式一:表单提交(最常见)
<form action={updateProfile}>

// 方式二:在 transition 里调用(非表单场景)
const [isPending, startTransition] = useTransition();
<button onClick={() => startTransition(() => updateProfile(fd))}>

// 方式三:直接 await(在另一个 Server Action / 服务端代码里)
const result = await updateProfile(fd);
⭐⭐ Server Actions 的四条安全纪律(面试高频)

① 【必须鉴权】
   它是「公开的 HTTP 端点」——任何人可以用 curl 直接调它。
   ❌ 相信客户端传来的 userId / role
   ✅ 在函数内部从 cookie/session 读当前用户(见 12.7)

② 【必须校验输入】
   不能假设「只有我的表单会调它」——
   FormData 里的任何字段都可能被伪造(含 hidden input、name、value)

③ 【不要返回敏感数据】
   Action 的返回值会「序列化传回客户端」→ 别把 token、内部 ID、堆栈返出去

④ 【注意幂等】
   Action 可能被「重复提交」(双击、网络重试)→ 写操作要考虑幂等键

⭐ 额外的工程实践:
   · 把 Action 放在独立文件('use server' 文件级指令)
   · Action 内做「参数 schema 校验」(zod)
   · 用 revalidatePath / revalidateTag 控制缓存失效粒度
   · 长耗时任务不要在 Action 里做(用户会一直 pending)→ 用「任务队列 + 轮询」

八、迁移与兼容

// ⚠️ useFormState → useActionState(改名)
// React 19 之前:
import { useFormState } from 'react-dom';
const [state, formAction] = useFormState(action, initialState);

// React 19:
import { useActionState } from 'react';
const [state, formAction, isPending] = useActionState(action, initialState);
// ⭐ 新版还多返回一个 isPending(不用再靠 useFormStatus 拿)
旧写法 新写法 说明
useFormState(react-dom) useActionState(react) 改名 + 换模块
useFormStatus 拿 pending useActionState 的 isPending 本组件内的提交用后者更直接
手写 isSubmitting state 上面的两种 不再需要手写
forwardRef ref 作为 prop React 19 起可直接接收
defaultProps(函数组件) 默认参数 React 19 已移除
⭐ 什么时候用 useFormStatus、什么时候用 useActionState 的 isPending?

· useActionState 的 isPending:适合「**提交按钮就在表单所在的组件里**」
  → 直接用它,少抽一层组件
· useFormStatus:适合「**提交按钮是独立复用的组件**」(如 <SubmitButton>)
  → 它不需要把 pending 从父组件透传下去,自己就能读到
· ⭐ 两者可以同时用(各取所需)

九、这一组 API 特有的事故现场

// ① 在渲染中创建 Promise 给 use() → 无限挂起
const data = use(fetchData());                          // ❌ 每次都新建 → 永远 pending
const [promise] = useState(() => fetchData());          // ✅

// ② 把 use() 包进 try/catch → Suspense 失效
try { const d = use(p); } catch {}                      // ❌

// ③ 用 useState 存「乐观值」→ 失去自动回滚
const [list, setList] = useState(comments);             // ❌ 失败后不会自动恢复
setList((l) => [...l, temp]);                           //    还得自己写回滚

// ④ 在事件处理里调 addOptimistic(不在 Action/transition 里)
<button onClick={() => addOptimistic('x')}>             // ❌ 警告且不生效

// ⑤ 把 useFormStatus 放在「和 form 同级」的组件里
function Form() {
  const { pending } = useFormStatus();                  // ❌ 读不到(不在 form 内部)
  return <form action={a}>…</form>;
}

// ⑥ 用 Server Action 但「不鉴权」→ 任何人可调
export async function deletePost(formData: FormData) {
  const id = formData.get('id');                        // ❌ 没有校验「当前用户有没有权限」
  await db.post.delete({ where: { id } });
}

// ⑦ Action 里「直接抛错」→ 错误边界接管,用户看到整页错误
async function action() { throw new Error('失败'); }      // ⚠️ 表单错误应该 return state
// ✅ 表单校验类错误应「return 到 state 里」,只有「不可恢复错误」才抛

// ⑧ 用乐观更新做「支付/下单」→ 失败时界面已显示成功
// ✅ 高风险操作不要乐观更新(见 7.6)

// ⑨ 忘记「Action 结束后表单会重置」→ 受控输入框值没清
//    ⚠️ React 只重置「非受控」表单;受控输入要自己 setState('')

// ⑩ 把「大文件上传」放进 Server Action(用户会长时间 pending)
// ✅ 走「客户端直传 + 服务端发凭证」

// ⑪ 用 useOptimistic 的返回值做「持久计算」(它随时会被丢弃)
useEffect(() => { sync(optimistic.length); }, [optimistic]);   // ❌ 反复触发

// ⑫ 临时项的 key 用 index / Math.random()
{optimistic.map((c, i) => <li key={i} />)}              // ⚠️ 乐观项增删时错位

// ⑬ 忘记「乐观状态只活在 Action 期间」→ 用它在「Action 之外」做交互
//    例如:乐观添加后立刻「点击它打开详情」—— 它可能在下一帧就消失了

// ⑭ 多个 Action 并发时用「同一个 useOptimistic」→ 乐观值互相影响
//    ✅ 按「作用域」拆开(不同列表用不同的 useOptimistic)

// ⑮ 把 useActionState 的 action 写成「内联箭头函数」→ 每次渲染都是新函数
<form action={(fd) => { ... }}>                          // ⚠️ 会破坏「表单状态」的稳定性
// ✅ 定义为「组件外的稳定函数」或「useCallback 包裹」
⭐ ⑮ 的深入解释:
   `<form action>` 接收的函数如果是「每次渲染都新建的」,
   React 会认为「表单的 action 变了」——虽然大多数情况下仍能工作,
   但会带来「状态/可访问性上的不稳定」(如 pending 状态抖动)。
   规范做法:把 Action 定义在「组件外」(普通函数或 'use server' 文件里)。

十、面试延伸

  1. 「use 和 useContext 有什么区别?」
    ① 调用位置——use(Ctx) 可以在条件与循环里调用(useContext 必须遵守 Hook 的顶层规则);② 能读 Promise——use 还可以读 Promise(未就绪时挂起到最近的 Suspense);③ 订阅粒度不变——两者都是「value 一变,所有消费者重渲染」(use 没有让订阅变细);④ useContext 未废弃。注意:use 仍必须在渲染期间调用,且不能放在 try/catch 里。

  2. 「为什么 use 不能放在 try/catch 里?」
    因为 use 的挂起机制就是「抛出一个 Promise」——React 在渲染时捕获这个 Promise 并去最近的 Suspense 渲染 fallback。如果外面包了 try/catch,Promise 会被你自己捕获,React 就看不到它 → 不会挂起 → 你会拿到「一个 Promise 对象」而不是数据(渲染出 [object Promise] 或直接报错)。那错误怎么办? use 只负责「等待」,Promise reject 时的错误会被抛出并由 ErrorBoundary 接管——所以标准组合仍是「ErrorBoundary 在外、Suspense 在内」。

  3. 「use(promise) 里的 Promise 为什么要『稳定』?」
    因为它决定了「挂起多久」。如果在渲染中创建(use(fetchData())),那么每次渲染都会新建一个未完成的 Promise → React 每次都挂起 → 无限挂起(页面永远停在 fallback)。正确做法有三种:① 由上层组件创建后作为 prop 传下来;② 用 useState 惰性初始化(const [p] = useState(() => fetch()));③ 由缓存或服务端提供(React 官方推荐的是「Server Component 里启动请求、把 Promise 传给客户端组件」)。

  4. 「useActionState 解决了什么问题?」
    它把「Action 的返回值」与「pending 状态」交给 React 管理,从而省掉三块手写代码:① e.preventDefault() + new FormData(e.currentTarget)——现在 Action 直接收到 FormData;② 手写的 isSubmitting——现在用 isPending;③ 结果与错误状态——现在由 state 统一承载(且能拿到「上一次的返回值」)。它还额外提供了两点:Action 结束后 React 会自动重置非受控表单;第三个参数 permalink 支持「JS 未加载时的渐进增强提交」。

  5. 「useFormStatus 为什么必须在 <form> 的子组件里调用?」
    因为 React 是通过「表单状态 Context」把 pending 等状态传下去的,而这个 Provider 由 <form> 元素内部提供。所以「和 <form> 同级的组件」处在 Provider 之外,读不到状态(拿到的是外层表单或初始值)。实践结论:需要 pending 的按钮/字段要抽成「form 的子组件」(这也让 <SubmitButton> 成为一个可复用组件,贴到哪个表单都能用);如果不想抽组件,可以直接用 useActionState 返回的 isPending。

  6. 「useOptimistic 的回滚是怎么实现的?」
    它没有回滚代码——因为乐观值本身就是「临时的叠加」:useOptimistic(realState, updateFn) 返回的是「真实状态 + 尚未落地的乐观修改」。当你调用 addOptimistic(v) 时,UI 立刻变成叠加后的结果;一旦 Action 结束(无论成功还是失败),这个叠加就被丢弃,UI 自动回到「真实状态的渲染结果」。所以「失败自动回滚」是它的语义副作用,而不是「你写的回滚逻辑」。代价是你不能控制回滚时机(Action 一结束就回滚)。

  7. 「useOptimistic 和 TanStack Query 的 onMutate 有什么区别?」
    ① 回滚——useOptimistic 自动(Action 结束即丢弃叠加),onMutate 需要手动保存 previous 并在 onError 里回滚;② 控制力——onMutate 更强(能精细控制缓存、失效、onSettled 时机),useOptimistic 更简单但不能控制回滚时机;③ 依赖——useOptimistic 只需要「Actions」,onMutate 需要「TanStack Query 的缓存」;④ 适用——useOptimistic 适合表单类提交(评论、点赞、勾选),onMutate 适合任何需要维护服务端缓存的场景。

  8. 「Server Action 有什么安全风险?」
    它是「公开的 HTTP 端点」——任何人都可以构造请求直接调用它(不经过你的 UI)。所以必须守四条纪律:① 鉴权——在函数内部从 cookie/session 读当前用户,绝不能相信客户端传来的 userId/role;② 输入校验——FormData 里的每个字段都可能被伪造(包括 hidden input);③ 不返回敏感数据——返回值会序列化传回客户端;④ 幂等——可能被重复提交(双击、网络重试),写操作要考虑幂等键。

  9. 「useOptimistic 里的 updateFn 为什么不能依赖外部变量?」
    因为多次调用 addOptimistic 时,React 会把这串更新「依次应用」(语义类似 useReducer 的队列)。updateFn(current, optimisticValue) 里的 current 是「上一次应用后的状态」(含之前的乐观叠加),所以:① 「追加」类操作连续调用会正确累加;② 如果 updateFn 依赖了「外部变量」(如组件里的 list 快照),就会出现「用旧快照计算」的错误(与 setState 的闭包问题同源)。结论:updateFn 应该是「只依赖 current 与 optimisticValue 的纯函数」。

  10. 「React 19 之后,表单还能用受控组件吗?」
    能。这组 Actions API 是「新增一种更省代码的写法」,不是「废弃受控组件」。选择标准依然是「是否需要实时响应输入」(见 12.6):① 需要(实时校验、格式化、联动、字数统计)→ 受控(value + onChange);② 不需要(提交时取最终值)→ 用 Actions(<form action> + useActionState)。实践中常常混用:输入框受控(做实时校验),提交走 Action(useActionState 管错误与 pending)。

  11. 「useActionState 和 useFormStatus 该用哪个拿 pending?」
    看「提交按钮与表单是否在同一个组件里」:① 同一组件 → 用 useActionState 返回的 isPending(少抽一层);② 按钮是独立复用的组件(<SubmitButton>)→ 用 useFormStatus(它自己就能读到所属表单的状态,不需要父组件透传 pending)。两者可以同时使用,各取所需。

  12. 「用 Actions 之后,乐观更新还需要自己写吗?」
    大多数「表单类乐观更新」不需要——useOptimistic 覆盖了「新增/删除/修改/点赞」这几类常见形态,且回滚免费。但两类场景仍需自己写:① 需要精细控制缓存(TanStack Query 的 onMutate + invalidateQueries);② 乐观状态要「跨越多个 Action」存活(useOptimistic 的值只活在单个 Action 期间,Action 一结束就回滚)。

一句话速记

React 19 这组 API 围绕「Action」展开:use(渲染期读 Promise/Context,可在条件/循环里用,但不能放 try/catch,Promise 必须稳定) + useActionState(统一管 state 与 isPending,省掉 preventDefault/FormData/手写 loading) + useFormStatus(子组件读 pending/data/method,必须在 <form> 内) + useOptimistic(乐观值是「临时叠加」,Action 结束自动丢弃 = 自动回滚,updateFn 必须是纯函数,addOptimistic 必须在 Action/transition 内调用) + Server Actions('use server',是公开 HTTP 端点 → 必须鉴权 + 校验输入 + 不返回敏感数据 + 考虑幂等);useFormState 已改名为 useActionState;「高风险操作不要乐观更新」「大文件上传不要放 Action 里」;受控组件没有被废弃——「需要实时响应就用受控,只取提交值就用 Actions」,实践中常常混用。

赞(0) 打赏
未经允许不得转载:陌上寒 » 2.15 React 19 新 Hooks 与 Actions

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫