Next.js 如何处理表单?(TS + Tailwind CSS + Shadcn UI + RHF + Zod + useOptimistic)

2,662 阅读9分钟

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

前言

本篇我们使用 Next.js + TypeScript + Tailwind CSS + Shadcn UI + React Hook Form + Zod + Server Actions + useOptimistic 实现一个简单又复杂的 TodoList。

效果如下:

next-form-1.gif

简单的地方在于效果看起来简单:当用户输入内容后,点击提交按钮或者按回车键即可提交表单数据,首先会进行数据校验,当数据校验通过后,提交按钮置灰表示不可操作,然后列表进行乐观更新,添加成功后,列表刷新,提交按钮恢复可用状态,表单重置,同时会有 Toast 提示。

复杂的地方在于实现的细节并不少,且为了日后应对更复杂的表单处理,提高可维护性,背后涉及的技术选型众多,需要相互协同,共同为开发者提供一个良好的开发体验

所使用的这些基本都是主流的技术选型,相信以后会是 Next.js 项目涉及表单处理的常态选择。其中:

  1. Next.js:React 首推的生产框架,支持 React 的最新特性
  2. TypeScript:如大家所见,现在越来越多的项目使用 TypeScript
  3. Tailwind CSS:目前 Tailwind CSS 在 GitHub 有 80k Stars、Npm 周下载量 733W,已经成为前端主流的 CSS 框架
  4. Shadcn UI:React UI 库,2023 年 JavaScript 领域 GitHub Stars 增长最多的开源项目。2023 年 1 月创建的项目,一年便涨了 39.5K Star,足以看出其火爆程度
  5. React Hook Form::老牌的用于 React 应用程序的表单验证和状态管理库。它提供了一组钩子,可以轻松创建和管理表单,而无需编写大量样板代码。GitHub 40.1k Star
  6. Zod::数据校验库,目前 Zod GitHub 31.5k Star,Npm 周均下载量 784W,几乎是前端做数据校验的第一选择。Next.js 官方文档里用的也正是 Zod
  7. Server Actions:Next.js 13 提出的新特性,对于 Next.js 项目,除非要开发的 API 用于三方调用,否则都可以使用 Server Actions 替代,Next.js 项目必学必用知识
  8. useOptimistic:React 18 新 hook,用于实现乐观更新

那就让我们开始吧!

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

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

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

1. 初始化项目

为了方便演示,创建一个空的 Next.js 项目:

npx create-next-app@latest

注意勾选使用 TypeScript、Tailwind CSS、src 目录、App Router:

image.png

安装 Shadcn UI 并进行初始化:

npx shadcn-ui@latest init

这里怎么选都行:

image.png

添加用到的 Shadcn UI 组件:

npx shadcn-ui@latest add form button input toast

Shadcn UI 会将组件代码写入 src/components/ui中。

运行:

npm i zod zod-i18n-map i18next

其中 zod-i18n-mapi18next是为了实现 Zod 的中文错误提示

运行:

npm i react-hook-form @hookform/resolvers

其中 @hookform/resolvers是为了让 React Hook Form 使用 Zod Schema 作为校验器

2. 完整代码实现

这个例子涉及的代码逻辑比较简单,我直接给大家最终的代码,相信大家看到完整的代码即可很快领悟。

新建 src/app/todo/page.tsx,代码如下:

import { findTodos } from "@/actions/todo";
import Todo from "./todo";

export default async function Page() {
  const todos = await findTodos();
  return <Todo todos={todos} />;
}

在这里代码中,因为使用 useOptimistic 和 React Hook Form 需要在客户端组件中,所以我们将主要的代码逻辑抽离到了 <Todo> 组件中,然后在服务端组件中请求列表数据,将请求到的列表数据传给 <Todo> 组件。

新建 src/lib/zod.ts,代码如下:

import i18next from "i18next";
import { z } from "zod";
import { zodI18nMap } from "zod-i18n-map";
// Import your language translation files
import translation from "zod-i18n-map/locales/zh-CN/zod.json";

// lng and resources key depend on your locale.
i18next.init({
  lng: "es",
  resources: {
    es: { zod: translation },
  },
});
z.setErrorMap(zodI18nMap);

// export configured zod instance
export { z };

这是为了实现 Zod 的汉化,因为当出现一些开发者未定义的错误时,Zod 默认返回的是英文,现在将其改为中文。组件导出 z,当我们定义 Zod Schema 的时候,需要使用该组件导出的 z

image.png

注:上图中的错误是通过手动修改提交给 Server Actions 的值来实现的,触发服务端的 Zod 校验后返回的错误信息,一般不会出现这种错误

新建 src/schema/todo.ts,代码如下:

import { z } from "@/lib/zod";

export const createTodoZodSchema = z.object({
  todo: z.string().min(1, { message: "请填写任务" }),
});

export type createTodoZodSchemaType = z.infer<typeof createTodoZodSchema>;

这是为了定义创建任务时的数据校验逻辑。之所以不只使用 React Hook Form 是因为 React Hook Form 的校验逻辑只能用在客户端,服务端不能相信客户端的数据,所以服务端也要做一遍校验,但前后端往往需要相同的校验逻辑。通过定义统一的 Schema,前后端可共享校验逻辑。

修改 src/app/layout.tsx,代码如下:

import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { Toaster } from "@/components/ui/toaster";
import "./globals.css";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={inter.className}>
        {children}
        <Toaster />
      </body>
    </html>
  );
}

修改 layout.tsx 主要是为了在布局中添加 <Toaster>组件,这样组件可以使用 toast() 方法。

新建 src/app/todo/todo.tsx,代码如下:

"use client";

import { useOptimistic, useRef, useTransition } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

import { Button } from "@/components/ui/button";
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { toast } from "@/components/ui/use-toast";

import { createTodo } from "@/actions/todo";
import {
  createTodoZodSchema,
  type createTodoZodSchemaType,
} from "@/schema/todo";

interface Props {
  todos: string[];
}

type optimisticTodoItem = {
  text: string;
  sending?: boolean;
};

export default function Page({ todos }: Props) {
  const [optimisticToDoList, addOptimisticTodo] = useOptimistic(
    todos.map((i) => ({ text: i })),
    (currentState: optimisticTodoItem[], { todo }: createTodoZodSchemaType) => {
      return [
        ...currentState,
        {
          text: todo,
          sending: true,
        },
      ];
    }
  );

  const [isPending, startTransition] = useTransition();

  const form = useForm({
    resolver: zodResolver(createTodoZodSchema),
    defaultValues: {
      todo: "",
    },
  });

  const { handleSubmit, reset, control } = form;

  const formRef = useRef<HTMLFormElement>(null);
  const onSubmit = handleSubmit(async (data: createTodoZodSchemaType) => {
    startTransition(async () => {
      addOptimisticTodo(data);
      const response = await createTodo(data);
      if (response?.error) {
        toast({
          title: "哎呦,出错了",
          description: response.error,
          variant: "destructive",
        });
      } else {
        toast({
          title: "恭喜您",
          description: "任务创建成功!",
        });
        reset();
      }
    });
  });

  return (
    <div className="p-4">
      <Form {...form}>
        <form ref={formRef} onSubmit={onSubmit} className="space-y-8 p-4">
          <FormField
            control={control}
            name="todo"
            render={({ field }) => (
              <FormItem>
                <FormLabel>快速改变人生的五件事情是:</FormLabel>
                <FormControl>
                  <Input placeholder="任务名称" {...field} />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />
          <Button type="submit" className="w-full" disabled={isPending}>
            添加任务
          </Button>
        </form>
      </Form>
      <ul className="list-decimal list-inside p-4">
        {optimisticToDoList.map(({ text, sending }, i) => (
          <li key={i}>
            {text} {!!sending && <small> (添加中...)</small>}
          </li>
        ))}
      </ul>
    </div>
  );
}

这是最核心的代码逻辑。

我们首先通过服务端组件的列表数据,构建了一个乐观更新列表(optimisticToDoList),为了能够展示出“添加中...”的效果,原本的数据应该是 ["运动"],我们改成了 [{"text": "运动", "sending": true}]结构。

然后是 React Hook Form 用法,通过 zodResolver(createTodoZodSchema)使 React Hook Form 引入 Zod Schema 作为数据校验,从而实现 React Hook Form 搭配 Zod 一起使用。

当提交表单数据的时候,首先会进行数据校验,数据校验通过后,会调用 onSubmit 函数。而在 onSubmit 函数中,我们将所有的操作都放到了 React 的 transition 中。

通常我们看到的乐观更新例子中,并不需要用到 transition,这是因为官方示例中都是放到 form 的 action 中,默认内置了 transition。如果你要在其他场景调用乐观更新,React 会出报错提示:

image.png

所以我们才使用 startTransition 包裹了乐观更新和服务端请求处理的代码。但是因为 startTransition 并不返回 Promise,所以 React Hook Form 无法通过 formState.isSubmiting 判断表单的提交状态,所以我们改为使用 useTransition 返回的 isPending 来判断提交状态,提交按钮的 disabled 状态正是根据 isPending 来判断的。

新建 src/actions/todo.ts,代码如下:

"use server";

import { revalidatePath } from "next/cache";

import {
  createTodoZodSchema,
  type createTodoZodSchemaType,
} from "@/schema/todo";

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

let data = ["阅读", "写作", "冥想"];

export async function findTodos() {
  return data;
}

export async function createTodo(todoData: createTodoZodSchemaType) {
  await sleep(1000);

  const result = createTodoZodSchema.safeParse(todoData);

  if (!result.success) {
    const errorMessages = result.error.issues.reduce((prev, issue) => {
      return (prev += issue.message);
    }, "");

    return {
      error: errorMessages,
    };
  }

  try {
    const todo = todoData.todo;
    data.push(todo);
  } catch (error) {
    return {
      error: "服务端创建错误",
    };
  }
  revalidatePath("/todo");
}

这是创建任务的服务端逻辑,如果服务端数据校验错误,返回校验的错误信息,如果数据库操作失败,返回服务端错误。当然这里我们为了避免引入数据库以及 ORM,使用数组操作模拟了查询数据库操作。当数据校验成功,使用 Next.js 的 revalidatePath更新/todo页面,页面数据得以刷新。

此时页面最终效果如下:

image.png

3. 其他实现方式

数据提交我们也可以改为使用 Form 的 action 属性来实现。修改 src/app/todo2/todo.tsx,代码如下:

"use client";

import { useOptimistic, useRef, useTransition } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useFormStatus } from "react-dom";
import { Button } from "@/components/ui/button";
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { toast } from "@/components/ui/use-toast";

import { createTodo } from "@/actions/todo";
import {
  createTodoZodSchema,
  type createTodoZodSchemaType,
} from "@/schema/todo";

interface Props {
  todos: string[];
}

type optimisticTodoItem = {
  text: string;
  sending?: boolean;
};

export default function Page({ todos }: Props) {
  const [optimisticToDoList, addOptimisticTodo] = useOptimistic(
    todos.map((i) => ({ text: i })),
    (currentState: optimisticTodoItem[], { todo }: createTodoZodSchemaType) => {
      return [
        ...currentState,
        {
          text: todo,
          sending: true,
        },
      ];
    }
  );

  const form = useForm({
    resolver: zodResolver(createTodoZodSchema),
    defaultValues: {
      todo: "",
    },
  });

  const { trigger, reset, control } = form;

  const handleFormAction = async (data: any) => {
    const res = await trigger();
    if (!res) return;

    const newTask = {
      todo: data.get("todo"),
    };

    addOptimisticTodo(newTask);
    const response = await createTodo(newTask);
    if (response?.error) {
      toast({
        title: "哎呦,出错了",
        description: response.error,
        variant: "destructive",
      });
    } else {
      toast({
        title: "恭喜您",
        description: "任务创建成功!",
      });
      reset();
    }
  };

  return (
    <div className="p-4">
      <Form {...form}>
        <form action={handleFormAction} className="space-y-8 p-4">
          <FormField
            control={control}
            name="todo"
            render={({ field }) => (
              <FormItem>
                <FormLabel>快速改变人生的五件事情是:</FormLabel>
                <FormControl>
                  <Input placeholder="任务名称" {...field} />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />
          <SubmitButton />
        </form>
      </Form>
      <ul className="list-decimal list-inside p-4">
        {optimisticToDoList.map(({ text, sending }, i) => (
          <li key={i}>
            {text} {!!sending && <small> (添加中...)</small>}
          </li>
        ))}
      </ul>
    </div>
  );
}

function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <Button type="submit" className="w-full" disabled={pending}>
      添加任务
    </Button>
  );
}

在这个例子中,我们构建了一个 handleFormAction 异步函数用作表单提交时的处理函数,然后通过 useForm 返回的 trigger 函数手动触发验证,数据校验通过后,手动从 FormData 中获取表单数据,构建提交数据,触发乐观更新和 Server Actions。

与之前不同的是,之前我们通过 useTransition 返回的 isPending 来判断提交状态,现在没有使用 transition,我们改为使用了 React 18 的 hook —— useFormStatus 来判断提交状态。为了使用 useFormStatus,我们需要将提交按钮声明到一个单独的组件中。这样才能正确获取表单状态。

虽然实现略有不同,但效果都是一样的:

最后

本篇我们讲解了如何快速使用这些技术选型构建一个表单处理项目。从这个项目出发,可以学习这些技术选型的基本使用方式,以及如何搭配使用,为处理更为复杂的表单处理场景打下技术基础。

Next.js 系列

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

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

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

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