React 19 新 hook —— useActionState 与 Next.js Server Actions 绝佳搭配

7,372 阅读9分钟

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!

前言

2024 年 4 月 25 日,React 发布了 React 19 RC 的介绍博客,其中介绍了新 hook —— useActionState

说是新 hook,其实就是 Canary 版中的 useFormState,只是添加了新功能以及做了重命名。

使用 useActionState,可以替代之前的 useFormStateuseFormStatus,和 Next.js 的 Server Actions 更是绝佳搭配。

本篇我们就详细介绍下为什么会有 useActionState 这个 hook 以及在 Next.js 项目中如何搭配 Server Actions 使用。

  1. 本篇已收录到掘金专栏《Next.js 开发指北》

  2. 系统学习 Next.js,欢迎入手小册《Next.js 开发指南》。基础篇、实战篇、源码篇、面试篇四大篇章带你系统掌握 Next.js!

欢迎围观我的“朋友圈”、加入“低调务实优秀中国好青年”前端社群,分享技术,带你成长。

使用 Next.js 15 与 React 19

创建一个 Next.js 项目:

npx create-next-app@latest

目前 Next.js 15 和 React 19 都在 RC 阶段,使用需要升级相关依赖项,运行:

npm i next@rc react@rc react-dom@rc

如果安装时遇到了依赖问题:

image.png

那就加个 --force 强制安装:

npm i next@rc react@rc react-dom@rc --force

1. 传统实现方式

使用 React 常遇到的场景就是执行数据突变,然后根据响应更新状态。

这样的说法有些“文绉绉”,大白话就是调用接口,然后根据接口返回的数据进行处理,比如更新列表、提示错误信息等。我们写个例子。

涉及的文件和目录如下:

app                 
└─ form
   ├─ actions.js   
   ├─ form.js      
   └─ page.js       

新建 app/form/page.js,代码如下:

import { findToDos } from './actions';
import AddToDoForm from './form';

export default async function Page() {
  const todos = await findToDos();
  return (
    <div className="p-4">
      <AddToDoForm />
      <ul className='list-decimal list-inside'>
        {todos.map((todo, i) => <li key={i}>{todo}</li>)}
      </ul>
    </div>
  )
}

新建 app/form/form.js,代码如下:

'use client'

import { useState } from 'react';
import { createToDo } from './actions';

export default function AddToDoForm() {
  const [todo, setTodo] = useState("");
  const [error, setError] = useState(null);
  const [isPending, setIsPending] = useState(false);

  const handleSubmit = async () => {
    setIsPending(true);
    const error = await createToDo(todo);
    setIsPending(false);
    if (error) {
      setError(error);
      return;
    }
  };

  return (
    <div className="flex flex-col gap-y-2 mb-2">
      <input type="text" name="todo" className="px-4 py-2 rounded shadow-sm ring-1 ring-inset ring-gray-300" value={todo} onChange={(event) => setTodo(event.target.value)} />
      <button type="submit" disabled={isPending} className="bg-indigo-600 disabled:bg-gray-500 py-2 rounded text-white" onClick={handleSubmit} >
        {isPending ? 'Adding' : 'Add'}
      </button>
      {error && <p>{error}</p>}
    </div>
  )
}

在这段代码中,我们声明了 3 个状态,一个状态用于获取输入框的值,一个状态用于处理挂起状态,一个状态用于处理错误。这就体现了传统实现方式的弊端:开发者需要手动处理挂起状态、错误状态等。

新建 app/form/actions.js,代码如下:

'use server'

import { revalidatePath } from "next/cache";

const sleep = ms => new Promise(r => setTimeout(r, ms));

let data = ['阅读', '写作', '冥想']
 
export async function findToDos() {
  return data
}

export async function createToDo(todo) {
  await sleep(500)

  if (Math.random() < 0.5) {
    return '创建失败'
  }

  data.push(todo)
  revalidatePath("/form");
}

在这段代码中,我们模拟了创建失败的情况,失败的时候会返回一个错误信息。

这就是一个最基本的 Next.js Server Actions 的例子。浏览器效果如下:

23.gif

在上图中,前两次添加都创建成功,列表得到更新。第三次添加的时候触发错误,显示了错误信息。

而且你还会发现(图中没有体现出来),如果继续添加,哪怕添加成功,之前的错误信息还会继续显示。如果要去除之前的错误信息,还需要开发者手动进行处理。

实际开发中也是如此,手动维护多个状态,如果某个边缘 Case 没有处理,很可能就会导致错误。

2. 过时的 useFormState 与 useFormStatus

为了优化这个过程,React 提供了 useFormStateuseFormStatus 这两个 hook 用来自动处理挂起状态、错误等。

涉及的文件和目录如下:

app                 
└─ form2
   ├─ actions.js   
   ├─ form.js      
   └─ page.js       

新建 app/form2/page.js,代码如下:

import { findToDos } from './actions';
import AddToDoForm from './form';

export default async function Page() {
  const todos = await findToDos();
  return (
    <div className="p-4">
      <AddToDoForm />
      <ul className='list-decimal list-inside'>
        {todos.map((todo, i) => <li key={i}>{todo}</li>)}
      </ul>
    </div>
  )
}

新建 app/form2/form.js ,代码如下:

'use client'

import { useFormState, useFormStatus } from 'react-dom'
import { createToDo } from './actions';

function SubmitButton() {
  const { pending } = useFormStatus()
  return (
    <button type="submit" disabled={pending} className="bg-indigo-600 disabled:bg-gray-500 py-2 rounded text-white">
      {pending ? 'Adding' : 'Add'}
    </button>
  )
}

export default function AddToDoForm() {
  const [state, formAction] = useFormState(createToDo, '')
  return (
    <form action={formAction} className="flex flex-col gap-y-2 mb-2">
      <input type="text" name="todo" className="px-4 py-2 rounded shadow-sm ring-1 ring-inset ring-gray-300" />
      <SubmitButton />
      {state && <p>{state}</p>}
    </form>
  )
}

可以看到使用 useFormStateuseFormStatus 后,整体代码量下降,而且不需要手动维护状态。

注意:useFormStatus 需要写在单独的组件中,所以我们才单独声明了一个 <SubmitButton> 组件。useFormState 返回的 formAction 可与 <form>集成,当 Action 成功时,React 会自动重置表单的不受控制组件。

新建 app/form2/actions.js,代码如下:

'use server'

import { revalidatePath } from "next/cache";

const sleep = ms => new Promise(r => setTimeout(r, ms));

let data = ['阅读', '写作', '冥想']
 
export async function findToDos() {
  return data
}

export async function createToDo(prevState, formData) {
  await sleep(500)

  if (Math.random() < 0.5) {
    return '创建失败'
  }

  const todo = formData.get('todo')
  data.push(todo)
  revalidatePath("/form2");
}

浏览器效果如下:

24.gif

在上图中,前两次添加的时候都失败了,显示了错误信息。第三次添加的时候终于成功了,之前的错误信息也自动去除,而且表单自动重置。

3. React 19 useActionState

查看第二个例子中的浏览器控制台,其实会发现有报错:

image.png

useFormState 在 React 19 RC 中已经更名为 useActionState

const [error, submitAction, isPending] = useActionState(
  async (previousState, newName) => {
   // ...
  },
  null,
);

注意:在 React 官方的例子里,第一个变量被命名为 error,这是因为成功的时候往往不需要返回什么,失败的时候却需要返回错误信息(Next.js 中成功的时候使用 revalidatePath,失败的时候才返回信息),其实更准确的表达应该是 state,这个字段不一定用来处理错误信息。

相比之前的 useFormStateuseActionState 还多了一个 isPending,这正是 useFormStatus 返回的 isPending 状态,所以使用 useActionState,不需要再使用 useFormStateuseFormStatus

涉及的文件和目录如下:

app                 
└─ form3
   ├─ actions.js   
   ├─ form.js      
   └─ page.js       

新建 app/form3/page.js代码如下:

import { findToDos } from './actions';
import AddToDoForm from './form';

export default async function Page() {
  const todos = await findToDos();
  return (
    <div className="p-4">
      <AddToDoForm />
      <ul className='list-decimal list-inside'>
        {todos.map((todo, i) => <li key={i}>{todo}</li>)}
      </ul>
    </div>
  )
}

新建 app/form3/form.js,代码如下:

'use client'

import { useActionState } from "react";
import { createToDo } from './actions';

export default function AddToDoForm() {
  const [state, formAction, isPending] = useActionState(createToDo, '')

  return (
    <form action={formAction} className="flex flex-col gap-y-2 mb-2">
      <input type="text" name="todo" className="px-4 py-2 rounded shadow-sm ring-1 ring-inset ring-gray-300" />
      <button type="submit" disabled={isPending} className="bg-indigo-600 disabled:bg-gray-500 py-2 rounded text-white">
        {isPending ? 'Adding' : 'Add'}
      </button>
      {state && <p>{state}</p>}
    </form>
  )
}

新建 app/form3/actions.js,代码如下:

'use server'

import { revalidatePath } from "next/cache";

const sleep = ms => new Promise(r => setTimeout(r, ms));

let data = ['阅读', '写作', '冥想']
 
export async function findToDos() {
  return data
}

export async function createToDo(prevState, formData) {
  await sleep(500)

  if (Math.random() < 0.5) {
    return '创建失败'
  }

  const todo = formData.get('todo')
  data.push(todo)
  
  revalidatePath("/form3");
}

浏览器效果如下:

25.gif

这里的效果同使用 useFormStateuseFormStatus的时候。

4. React 19 useFormStatus

注意:虽然 useFormState 被废弃并更名为 useActionState,但 useFormStatus 依然可以正常使用。

因为有的时候,组件需要访问 <form>的信息,虽然可以使用 useActionState 获取 pending 状态然后将该状态通过 props 一层一层传递,但其实也可以通过 Context 完成,为了处理更加方便,React 添加了 useFormStatus

import {useFormStatus} from 'react-dom';

function DesignButton() {
  const {pending} = useFormStatus();
  return <button type="submit" disabled={pending} />
}

简单的来说,useFormStatus 会自动读取父 form 的状态,而无须通过 props 传递。所以依然可以用,组件层级过深的时候,建议使用 useFormStatus

5. useActionState 不仅用于表单

目前我们举的例子都是用在表单上,这可能会让初学者以为 useActionState 就是用来处理表单的,其实不然。

回顾下 useActionState 的用法:

const [state, formAction, isPending] = useActionState(fn, initialState);

虽然 form 元素有一个 action 属性,但此 action 非彼 action。在 React 中,按照惯例,使用异步转换的函数被称之为“Action”。useActionState 中的 Action 表达的其实是异步函数,而非用在 form action 的意思。

我们举个将 useActionState 用在其他元素的例子:

涉及的文件和目录如下:

app                 
└─ form4
   ├─ actions.js   
   ├─ form.js
   ├─ delbtn.js
   └─ page.js       

新建 app/form4/page.js,代码如下:

import { findToDos } from './actions';
import AddToDoForm from './form';
import DeleteBtn from './delbtn';

export default async function Page() {
  const todos = await findToDos();
  return (
    <div className="p-4">
      <AddToDoForm />
      <ul className='list-decimal list-inside'>
        {todos.map((todo, i) => <li key={i} className='mb-2'>{todo}<DeleteBtn id={i} /></li>)}
      </ul>
    </div>
  )
}

我们在列表后又添加了一个删除按钮。

新建 app/form4/form.js,代码同 form3,代码如下:

'use client'

import { useActionState } from "react";
import { createToDo } from './actions';

export default function AddToDoForm() {
  const [state, formAction, isPending] = useActionState(createToDo, '')

  return (
    <form action={formAction} className="flex flex-col gap-y-2 mb-2">
      <input type="text" name="todo" className="px-4 py-2 rounded shadow-sm ring-1 ring-inset ring-gray-300" />
      <button type="submit" disabled={isPending} className="bg-indigo-600 disabled:bg-gray-500 py-2 rounded text-white">
        {isPending ? 'Adding' : 'Add'}
      </button>
      {state && <p>{state}</p>}
    </form>
  )
}

新建 app/form4/delbtn.js,代码如下:

'use client'

import { useActionState } from "react";
import { deleteTodo } from './actions';

export default function DeleteBtn({id}) {
  const [state, action, isPending] = useActionState(deleteTodo, null)

  return (
    <button 
      disabled={isPending}
      onClick={() => {  
        action(id)
      }}
      className="bg-indigo-600 disabled:bg-gray-500 py-2 rounded text-white px-2 ml-2">
      {isPending ? 'Deleting' : 'Delete'} {state}
    </button>
  )
}

这里我们使用了 useActionState,并将 action 函数用在了按钮元素的 onClick 事件中。

新建 app/form4/actions.js,代码如下:

'use server'

import { revalidatePath } from "next/cache";

const sleep = ms => new Promise(r => setTimeout(r, ms));

let data = ['阅读', '写作', '冥想']
 
export async function findToDos() {
  return data
}

export async function createToDo(prevState, formData) {
  await sleep(500)

  if (Math.random() < 0.5) {
    return '创建失败'
  }

  const todo = formData.get('todo')
  data.push(todo)
  
  revalidatePath("/form4");
}

export async function deleteTodo(id) {
  await sleep(500)

  if (Math.random() < 0.5) {
    return '删除失败'
  }

  data.splice(id, 1)
  revalidatePath("/form4");
}

浏览器效果如下:

26.gif

点击删除按钮的时候,如果删除失败,会渲染返回的 state 信息。

如果经常写 Server Actions,你可能会这样错误使用 useActionState:

'use client'

import { useActionState } from "react";
import { deleteTodo } from './actions';

export default function DeleteBtn({id}) {
  const [state, action, isPending] = useActionState(deleteTodo, null)

  return (
    <button 
      disabled={isPending}
      onClick={async () => {
        // 这样写是错误的
        const response = await action(id)
        if (response) alert(response)
      }}
      className="bg-indigo-600 disabled:bg-gray-500 py-2 rounded text-white px-2 ml-2">
      {isPending ? 'Deleting' : 'Delete'} {state}
    </button>
  )
}

这样写是无效的,response 是一直是 undefiend。使用 useActionState,返回的信息要使用 state 获取。

如果直接使用 Server Actions,你可以这样写:

'use client'

import { useActionState } from "react";
import { deleteTodo } from './actions';

export default function DeleteBtn({id}) {
  const [state, action, isPending] = useActionState(deleteTodo, null)

  return (
    <button 
      disabled={isPending}
      onClick={async () => {  
        const response = await deleteTodo(id)
        if (response) alert(response)
      }}
      className="bg-indigo-600 disabled:bg-gray-500 py-2 rounded text-white px-2 ml-2">
      {isPending ? 'Deleting' : 'Delete'} {state}
    </button>
  )
}

总结一下就是,使用 Server Actions,一种是直接使用:

'use client'

import { deleteTodo } from './actions';

export default function DeleteBtn({id}) {

  return (
    <button 
      onClick={async () => {  
        const response = await deleteTodo(id)
        if (response) alert(response)
      }}
      className="bg-indigo-600 disabled:bg-gray-500 py-2 rounded text-white px-2 ml-2">
      Delete
    </button>
  )
}

使用这种方式你可以通过 async/await 获取 Server Actions 返回的结果。

一种是结合 useActionState:

'use client'

import { useActionState } from "react";
import { deleteTodo } from './actions';

export default function DeleteBtn({id}) {
  const [state, action, isPending] = useActionState(deleteTodo, null)

  return (
    <button 
      disabled={isPending}
      onClick={() => {  
        action(id)
      }}
      className="bg-indigo-600 disabled:bg-gray-500 py-2 rounded text-white px-2 ml-2">
      {isPending ? 'Deleting' : 'Delete'} {state}
    </button>
  )
}

使用这种方式,Server Actions 的返回结果需要从 state 中获取。

总结

本篇我们讲解了 React/Next.js 如何处理数据突变(data mutation)场景,从传统实现到 useFormState + useFormStatus 再到现在的 useActionState,目的在于自动管理挂起状态、错误、Form 等。相信大家通过 4 个实例的代码变化,更能深刻理解使用 useActionState 的好处。

在 Next.js 项目使用 Server Actions 时应该多搭配 useActionState 一起使用。

Next.js 系列

本篇已收录在掘金专栏 《Next.js 开发指北》,该系列一共 24 篇。

系统学习 Next.js,欢迎入手小册《Next.js 开发指南》。基础篇、实战篇、源码篇、面试篇四大篇章带你系统掌握 Next.js!

此外我还写过 JavaScript 系列TypeScript 系列React 系列Next.js 系列冴羽答读者问等 14 个系列文章, 全系列文章目录:github.com/mqyqingfeng…

通过文字建立交流本身就是一种缘分,欢迎围观我的“朋友圈”、加入“低调务实优秀中国好青年”前端社群,分享技术,带你成长。