@location-state/conformをリリースした
この記事はlocation-stateをconformに対応させるために開発した、@location-state/conformの紹介記事です。
location-stateとは
location-stateは履歴位置に同期する状態管理ライブラリです。主にNext.jsをサポートしています。
Next.jsなどを採用している場合、ページ内のuseState
は遷移時のunmountで状態が破棄され、ブラウザバック時には復元されません。そのため、アコーディオンやform要素の状態はブラウザバック時にはリセットされてしまいます。これはNext.jsに限らず、ReactやVueなどをベースにしたモダンなフロントエンドフレームワークを採用して、クライアントサイドルーティングが発生する場合に起きがちな挙動です。クライアントサイドルーティングが不在なMPAでは、bfcacheやブラウザ側の復元処理によってDOMの状態が復元されます。
筆者もサイト利用時に、formの入力途中で前のページの情報を確認するためにブラウザバックし、再度formに戻ってきたら入力内容が消えていた経験があります。これは、従来のMPAなら復元されていたことでしょう。SPAとMPAでブラウザバック時の挙動が異なることはユーザーにとって望ましくありません。
しかし、開発者が自前で履歴ごとに復元されるような状態管理を実装するのは非常に大変です。これらの課題を解消すべく開発されたのがlocation-stateです。
より詳細にlocation-stateについて知りたい方は、リリース時に書いた以下の記事をご参照ください。
conform
さて、今回はこのlocation-stateがconformに対応したわけなので、conformについても簡単に紹介します。conformはreact-hook-formなどより後発な、Reactのformライブラリです。
主な特徴としては以下が挙げられます。
- zodなどとの統合が容易
- 強力なTypeScriptサポート
- Server ActionsやReactのhooksとの親和性が高い
- Progressive Enhancementに対応
筆者はconformを、Server Actions時代のformライブラリとして台頭する可能性があると考え、非常に注目しています。以下の記事でより詳細に紹介しているので、conformに馴染みのない方はぜひご覧ください。
@location-state/conform
conformでもブラウザバック・フォワード時にちゃんと状態が復元されるようにlocation-stateと統合したのが、今回開発した@location-state/conform
です。例によってkoichikさんに監修いただきました。
@location-state/core
と併用して利用できます。以降は@location-state/conform
利用前後での挙動の違いや、利用方法について紹介したいと思います。
@location-state/conformなしでの挙動
まず素のconformの実装と挙動を確認します。location-stateのリポジトリにあるexampleを簡易化しつつ確認していきたいと思います。
Next.jsでconformを使う時は、@conform-to/react
と@conform-to/zod
を利用します。Server Actionsではzod schemaをparseWithZod
と併用してsubmission
を作成し、必要に応じてsubmission.reply()
するのが基本的な使い方になります。
// action.ts
"use server";
import { parseWithZod } from "@conform-to/zod";
import { redirect } from "next/navigation";
import { User } from "./schema";
export async function saveUser(prevState: unknown, formData: FormData) {
const submission = parseWithZod(formData, {
schema: User,
});
if (submission.status !== "success") {
return submission.reply();
}
redirect("/success");
}
formコンポーネント側ではuseForm
を利用してform
オブジェクトとfields
オブジェクトを取得します。この際onValidate
でvalidation挙動を設定できるので、return parseWithZod(formData, { schema: User });
とすれば、zod schemaに従ったvalidationが行われます。
あとは適宜form要素でform
やfields
を参照することでformを組み立てるのがconformの基本的な使い方です。
// form.tsx
"use client";
import { getFormProps, getInputProps, useForm } from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod";
import { useFormState } from "react-dom";
import { saveUser } from "./action";
import { User } from "./schema";
export default function Form({ storeName }: { storeName: "session" | "url" }) {
const [lastResult, action] = useFormState(saveUser, undefined);
const [form, fields] = useForm({
lastResult,
onValidate({ formData }) {
return parseWithZod(formData, { schema: User });
},
});
return (
<form {...getFormProps(form)} action={action} noValidate>
<div style={{ display: "flex", columnGap: "10px" }}>
<label htmlFor={fields.firstName.id}>First name</label>
<input
{...getInputProps(fields.firstName, {
type: "text",
})}
key={fields.firstName.key}
/>
<div>{fields.firstName.errors}</div>
</div>
<div style={{ display: "flex", columnGap: "10px", marginTop: "10px" }}>
<label htmlFor={fields.lastName.id}>Last name</label>
<input
{...getInputProps(fields.lastName, {
type: "text",
})}
key={fields.firstName.key}
/>
<div>{fields.lastName.errors}</div>
</div>
<div style={{ display: "flex", columnGap: "10px" }}>
<button type="submit">submit</button>
<button type="submit" {...form.reset.getButtonProps()}>
Reset
</button>
</div>
</form>
);
}
実際にこれで作った画面は以下のようになります。
初期状態
入力後
しかし前述の通り、入力後にリロードやブラウザバックを行うと初期状態に戻ってしまいます。
ブラウザバック・フォワード後
これをブラウザバック・フォワード時に復元されるようにするのが、@location-state/conform
です。@location-state/conform
を導入してリロード時やブラウザバック時の復元を実装してみましょう。
@location-state/conformを追加・実装
まず、@location-state/core
と@location-state/conform
を追加します。
$ pnpm add @location-state/core @location-state/conform
Providerを設定する必要があるので、app/layout.tsx
にClient ComponentsでProviderを追加します。
// app/providers.tsx
"use client";
import { LocationStateProvider } from "@location-state/core";
import type { ReactNode } from "react";
export function Providers({ children }: { children: ReactNode }) {
return <LocationStateProvider>{children}</LocationStateProvider>;
}
// app/layout.tsx
import { Providers } from "./providers";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
これで準備ができたので、次はconformを利用してる部分を修正します。@location-state/conform
はuseLocationForm
というhooksを提供しており、formOptions
とgetLocationFormProps
を取得できます。前者はconformのuseForm
のオプション、後者はgetFormProps
をラップした物になります。
// form.tsx
"use client";
// ...
import { useLocationForm } from "@location-state/conform";
// ...
export default function Form({ storeName }: { storeName: "session" | "url" }) {
// ...
const [formOptions, getLocationFormProps] = useLocationForm({
location: {
name: "static-form",
storeName,
},
});
const [form, fields] = useForm({
// ...
...formOptions,
});
return (
<form {...getLocationFormProps(form)} action={action} noValidate>
// ...
</form>
);
}
これだけで、ブラウザバック時にもフォームの状態が復元されるようになります。実際の挙動を確認してみましょう。
入力時
ブラウザバック・フォワード後
ちゃんと入力してた値が復元されています。もちろん、リロード時にもこの値は復元されます。
動的formの対応
conformは動的にフィールドを追加するようなformにも対応しており、@location-state/conform
も同様に動的なformに対応しています。使い方は上記のような静的なformと変わらないですが、exampleに実装があるので必要な方は参考にしてみてください。
感想
開発中、formが空になる体験はやっぱりかなり辛いなぁと改めて感じました。多くの方がブラウザバックのことをあまり気にせず実装していると思うのですが、ユーザーにとってはかなり重要な体験だと思います。特にformでは、住所などの長い情報を入力したのに消えてしまうと再度入力するのがとても億劫になります。こういった体験にストレスを感じたことのある方は多いのではないでしょうか?
この気持ちを減らすべく、location-stateがもっと多くの人に使ってもらえたら嬉しいです。
Discussion