译:深入探讨现代 React 中的表单

原文:https://www.epicreact.dev/react-forms
作者:Kent C. Dodds
译者:ChatGPT 4 Turbo

编者注:不要用 useFormStatus,用 useActionState 和 useOptimistic。关于 React 19 表单处理这块的介绍,感觉 447 – 《笔记:React Unpacked:A Roadmap to React 19》 更清楚一些。

在这篇文章中,我们将深入探讨如何使用现代 React API 构建表单。我们不会使用第三方库或框架。理解这些较低级别的原语将在你使用库和框架时为你解锁它们。

我最近听到有人说,网络应用程序只是数据库之上的一个界面。这实际上相当准确。自网络开始以来,现代网络应用程序就有两个方面:

  1. 用户查看数据
  2. 用户修改数据

将数据加载进网络应用程序是一个相当直接的提议。只有当我们开始讨论需要修改数据并保持事物同步时,事情才会变得更加复杂。

从第一天起,HTML 就支持了这两种用例的机制。

查看数据

这允许用户导航到另一个页面查看更多数据:

<a href="/remix-utah/events/301213597">Remix Meetup June 🏖</a>

允许用户导航到另一个页面查看更多数据,但通过包含用户输入的数据在生成的 URL 中,能让他们控制返回的数据:

<form action="/remix-utah/events/search">
  <label for="event-search">查询</label>
  <input id="event-search" type="search" name="query" />
  <button type="submit">搜索</button>
</form>

比如说,用户在输入框中输入 “August” 并按回车键。多亏了 action,用户将会跳转到 /remix-utah/events/search?query=August。所以,默认情况下,<form> 就像 <a> 一样,它具有允许用户提供输入的特殊能力。

修改数据

对于 <form><a> 来说,导航都是通过 GET 请求执行的,这是有道理的,因为你正试图获取(GET)数据。但如果用户试图 修改 数据呢?

例如,假设你希望能够通过 /remix-utah/events/301213597/join 报名参加一个活动。如果我们将该端点设为 GET,那么我们就可以这样做:

<a href="/remix-utah/events/301213597/join">加入 6 月 Remix Meetup 🏖</a>

这当然是可行的(如果你想,甚至可以使用带有提交按钮的 <form>)。然而,这也会使你的用户面临一个潜在问题:意外或恶意修改数据。因为 GET 请求被设计为幂等的(它们应该可以被反复调用而不会有可观察的效果),将它们用于改变状态的操作可能会导致意想不到的后果。

此外,如果你使用 GET 请求制作登录表单,然后用户提交他们的密码,URL 看起来像这样:/login?username=kody&password=kodylovesyou!用户的提交在明文中一览无余!

这是我们拥有 POST 请求的部分原因,虽然你无法让 <a> 使用 POST 而不是 GET,但你可以用 <form> 做到这一点。

POST 请求被设计用于向指定资源提交要处理的数据。没有 HTML 的功能能在不经用户互动的情况下触发 POST 请求,所以它们可以安全地被用来改变服务器的状态,这非常适合我们加入活动的场景。以下是你如何使用表单来 POST 数据的方法:

<form action="/remix-utah/events/301213597/join" method="POST">
  <button type="submit">加入 6 月 Remix Meetup 🏖</button>
</form>

通过使用 method="POST",我们指示这个表单旨在修改服务器上的数据。服务器然后会处理这个请求并相应更新其状态,确保记录用户加入活动的意图。

使用 JavaScript 处理数据变更

传统表单提交会导致整个页面重新加载,现代网络应用程序经常使用 JavaScript 异步处理这些动作,提供更平滑的用户体验。

这是你如何用 JavaScript 处理表单提交的一个例子:

<form id="join-event-form">
	<button type="submit">加入 6 月 Remix Meetup 🏖</button>
</form>

<script type="module">
	const form = document.getElementById('join-event-form')
	form.addEventListener('submit', async (event) => {
		event.preventDefault()

		const response = await fetch('/remix-utah/events/301213597/join', {
			method: 'POST',
			headers: { 'Content-Type': 'application/json' },
			body: JSON.stringify({ /* 任何必要的数据都可以在这里 */ }),
		})

		if (response.ok) {
			alert('成功加入活动!')
		} else {
			alert('加入活动失败。')
		}
	})
</script>

这个脚本拦截表单提交,阻止默认的浏览器行为,而是向服务器发送一个异步的 POST 请求。通过这种方式,页面不需要重新加载,用户可以立即收到他们行动的反馈。

你可能会注意到在这个例子中缺少了 method="POST",那是因为由于我们阻止了浏览器的默认行为,它并不是严格必要的。

虽然阻止整页刷新很好,但我们失去了很多浏览器为我们自动执行的操作,当我们阻止默认行为时,这会导致我们经常在应用程序中发现的错误。

例如,竞态条件和数据重新验证。话虽如此,如果你有一个帮助你处理这些问题的框架(如 React Router/Remix),那么这个努力是值得的,因为它提升了用户体验。

React 表单操作

突变(Mutations)是动态网页应用中重要且常见的部分。链接到应用的不同部分也是如此。然而,突变数据是复杂的,因为一旦用户执行了突变,他们可能正在查看的 UI 的部分可能是过时的。

现在,要清楚的是,使用常规浏览器突变并没有问题,因为整页刷新意味着用户总是看到服务器提供的最新内容。然而,如果我们专注于提供尽可能最好的用户体验,整页刷新是不可接受的。但这意味着我们需要在突变之后更新页面上的数据。

在客户端应用中管理链接和表单中复杂的另一个因素是管理等待状态。默认情况下,浏览器会显示一个旋转器(通常在 favicon 的位置),但当我们阻止默认行为时,我们需要考虑在转换期间给用户反馈。

React 对于转换有一个很好的解决方案 转换,但这只是表单故事的一半。因此,由于这个添加的复杂性,React 19 有一个内置机制用于处理表单,称为“动作”。

就像在 HTML 表单中使用 action 属性一样,你在 React 组件中使用 action 属性。然而,除了提供一个 URL,你也可以提供一个将在表单提交时调用的函数。

function JoinEventForm() {
	async function joinEvent(formData) {
		const response = await fetch('/remix-utah/events/301213597/join', {
			method: 'POST',
			body: formData,
		})

		if (response.ok) {
			alert('成功加入活动!')
		} else {
			alert('加入活动失败。')
		}
	}

	return (
		<form action={joinEvent}>
			<button type="submit">加入 Remix Meetup 6 月 🏖</button>
		</form>
	)
}

如果你对使用 onSubmit 属性的 React 表单很熟悉,我希望你注意这里的一些关键区别:

  1. 不需要添加 event.preventDefault ,因为 React 已经为我们处理了
  2. action 被自动当作一个过渡处理,所以在 action 期间触发组件挂起的任何事情都将是同一个过渡的一部分。稍后会详细介绍。
  3. 我们可以使用 useFormStatus 钩入这个 action 的待处理状态。
  4. React 管理错误和竞态条件,以确保我们表单的状态始终正确(没有无限的加载指示器)。

待处理状态

有几种方法可以管理这种交互的待处理状态。

useFormStatus

如果你熟悉 React 应用中 context 的工作方式,可以将 <form> 组件视为一个提供者,并将 useFormStatus 钩子视为一个访问该提供者数据的函数。从根本上说,React 中的 <form> 管理表单状态的状态,我们可以在 <form> 的任何子组件中访问这个状态。因此,要访问我们表单的待处理状态(当提交正在处理中时),我们需要创建一个子组件。

function JoinButton({ children }: { children: React.ReactNode }) {
	const { pending } = useFormStatus()

	return (
		<button type="submit">
			{pending ? '正在加入...' : children}
		</button>
	)
}

function JoinEventForm() {
	async function joinEvent(formData) {
		const response = await fetch('/remix-utah/events/301213597/join', {
			method: 'POST',
			body: formData,
		})

		if (response.ok) {
			alert('成功加入活动!')
		} else {
			alert('加入活动失败。')
		}
	}

	return (
		<form action={joinEvent}>
			<JoinButton>加入 Remix Meetup 6 月 🏖</JoinButton>
		</form>
	)
}

关于 useFormStatus 钩子 的酷之处在于,它还能让你访问表单的其他信息(包括提交的数据)。因此,如果你正在构建登录表单,你可以让待处理状态显示为:登录名为 {data.get('username')}

不过我不太喜欢的一点是,它要求创建一个全新的组件来使用表单的状态,所以让我们看看另一种方法。

useActionState

最简单的解释 useActionState 的方式是通过一个例子:

const JOIN_URL = '/remix-utah/events/301213597/join'

async function joinEvent(
	previousState: { joined: boolean },
	formData: FormData,
) {
	const response = await fetch(JOIN_URL, {
		method: 'POST',
		body: formData,
	})

	if (response.ok) {
		return { joined: true }
	} else {
		return { joined: false }
	}
}

function JoinEventForm() {
	const [state, formAction, isPending] = useActionState(
		joinEvent,
		{ joined: false },
		JOIN_URL,
	)

	return (
		<div>
			{state.joined ? (
				<p>到时见!</p>
			) : (
				<form action={formAction}>
					<button type="submit">
						{isPending ? '正在加入...' : '加入六月 Remix 聚会 🏖'}
					</button>
				</form>
			)}
		</div>
	)
}

好的,这里发生的事情是 useActionState 接受一个函数(joinEvent),一些初始状态({ joined: false }),以及可选的永久链接 URL(JOIN_URL)。然后,它会返回一个数组,包含当前状态(state),你可以调用来触发操作的函数(formAction),以及表单提交是否处于待处理状态(isPending)。

joinEvent 函数接受先前的状态。在某种程度上,你可以把它看作是类似 useReducer 的 reducer。当我们的表单被提交时,它的 action 会用 formData 被调用。我们的 action 就是 formActionformAction 函数将用当前状态,然后按任何调用时的参数,调用 joinEvent。我知道这可能有点混乱,所以让我通过过分啰嗦的解释来说明:

function JoinEventForm() {
	const [state, formAction, isPending] = useActionState(
		(prevState, ...args) => joinEvent(prevState, ...args),
		{ joined: false },
		JOIN_URL,
	)

	return (
		<div>
			{state.joined ? (
				<p>见你在那里!</p>
			) : (
				<form
					action={(formData) => {
						const args = [formData]
						formAction(...args)
					}}
				>
					<button type="submit">
						{isPending ? '正在加入...' : '加入 Remix Meetup 6月 🏖'}
					</button>
				</form>
			)}
		</div>
	)
}

action 属性会被调用,并传入 formData,我们随后创建了一个叫做 args 的数组,这个数组只包含一个 formData 元素,然后我们将它传递给 formActionformAction 接下来会调用我们的内联函数,传入 prevState 和其余参数(在我们的案例中,那就是 formData,但就 useActionState 而言,它不一定非要是这样。它仅仅是转发任何存在的参数。

自此,我们的 joinEvent 函数将进行一些异步操作,然后返回一个值,这个值结束转换并触发我们的表单使用返回值的 state 重新渲染。

当然,在转换期间,isPending 会是 true,所以我们可以展示我们的等待状态。然而,我们无法在 action 外部访问 formData,因此我们无法使用表单提交作为我们的等待 UI 的一部分,就像我们能够使用 useFormStatus 那样。但我们还能做更多事情!

在我们讨论那个之前,我将简要提及 useActionState 的第三个参数被称为 permalink,它用于服务器渲染和渐进增强。React 会用你提供的 URL 设置 action 在服务器渲染期间。这样如果表单在 React 有机会在页面上加载之前被提交了,常规浏览器行为将接管并使用我们习惯的全页刷新功能提交表单(你只需要确保你的服务器能够正确处理表单提交)。为渐进增强欢呼!

useOptimistic

向用户展示待处理任务的反馈很重要也很有用。如果待处理状态看起来就像完成状态一样(可能有一个微妙的指示,表明它实际上还未完成),那就太棒了。这方面的一个好例子是当你发送 Slack/Discord 消息时。消息会出现在消息列表中,但它会稍微透明一些,给你一种尚未完成的感觉。

你可以通过 useOptimistic 来做到这一点:

// ...

function JoinEventForm() {
	const [state, formAction] = useActionState(
		joinEvent,
		{ joined: false },
		JOIN_URL,
	)
	const [optimisticJoined, setOptimisticJoined] = useOptimistic(state.joined)

	return (
		<div>
			{optimisticJoined ? (
				// 如果我们还没完成加入,就显示稍微褪色一点...
				<p style={{ opacity: state.joined ? 1 : 0.8 }}>到时候见!</p>
			) : (
				<form
					action={(formData) => {
						setOptimisticJoined(true) // 乐观地将状态设置为已加入
						return formAction(formData)
					}}
				>
					<button type="submit">
						加入 Remix Meetup 6月 🏖
					</button>
				</form>
			)}
		</div>
	)
}

useOptimistic 钩子允许你在网络请求完成之前立即更新 UI 以反映用户的操作。如果实际请求失败,状态会被还原到之前的状态。这通过减少操作的感知延迟,提供了更平滑的用户体验。

我们甚至可以从按钮中移除待处理的 UI,但我们会给成功信息添加一点透明度,给人一种事情还没有完全解决的印象。你可能也想考虑为视觉障碍用户提供的体验。React 为我们构建这类体验提供了所需的一切!

你甚至可以执行多个步骤,并通过 useOptimistic 使用户时刻更新发生了什么。这太棒了:

// ...

function JoinEventForm() {
	const [state, formAction] = useActionState(
		joinEvent,
		{ joined: false },
		JOIN_URL,
	)
	const [optimisticMessage, setOptimisticMessage] = useOptimistic("")

	return (
		<div>
			{state.joined ? (
				<p>到时候见!</p>
			) : (
				<form
					action={async (formData) => {
						setOptimisticMessage("正在加入 meetup...")
						await formAction(formData)
						setOptimisticMessage("正在发送通知...")
						await sendNotification()
					}}
				>
					<p>{optimisticMessage}</p>
					<button type="submit">
						加入 Remix Meetup 6月 🏖
					</button>
				</form>
			)}
		</div>
	)
}

在这个示例中,我们使用了 useOptimistic 钩子在表单提交过程的多个阶段向用户提供反馈。最初,我们将乐观消息设置为 “加入聚会中…”,然后等待 formAction。一旦完成,我们更新消息为 “发送通知中…” 并等待 sendNotification 函数。

结论

我认为只用 React 而不需要借助于任何其他库就能拥有所有这些功能实在是太棒了。真正酷的是你 看不见 的部分,但却如声明式地管理错误、等待转换以及恰当地处理竞态条件一样得到了支持。而且,一个很棒的乐观用户界面(Optimistic UI)故事也是令人赞叹的。我们还没来得及讨论这是如何通过 "use server" 指令与服务器集成的,但那也是一个巨大的胜利,我们将来必须探索。

我希望这篇文章能帮你更清晰地了解 React 19 的表单基元能做些什么。