📝

@location-state/conformをリリースした

2024/05/16に公開

この記事はlocation-stateをconformに対応させるために開発した、@location-state/conformの紹介記事です。

location-stateとは

location-stateは履歴位置に同期する状態管理ライブラリです。主にNext.jsをサポートしています。

https://github.com/recruit-tech/location-state

Next.jsなどを採用している場合、ページ内のuseStateは遷移時のunmountで状態が破棄され、ブラウザバック時には復元されません。そのため、アコーディオンやform要素の状態はブラウザバック時にはリセットされてしまいます。これはNext.jsに限らず、ReactやVueなどをベースにしたモダンなフロントエンドフレームワークを採用して、クライアントサイドルーティングが発生する場合に起きがちな挙動です。クライアントサイドルーティングが不在なMPAでは、bfcacheやブラウザ側の復元処理によってDOMの状態が復元されます。

筆者もサイト利用時に、formの入力途中で前のページの情報を確認するためにブラウザバックし、再度formに戻ってきたら入力内容が消えていた経験があります。これは、従来のMPAなら復元されていたことでしょう。SPAとMPAでブラウザバック時の挙動が異なることはユーザーにとって望ましくありません。

しかし、開発者が自前で履歴ごとに復元されるような状態管理を実装するのは非常に大変です。これらの課題を解消すべく開発されたのがlocation-stateです。

より詳細にlocation-stateについて知りたい方は、リリース時に書いた以下の記事をご参照ください。

https://zenn.dev/akfm/articles/location-state

conform

さて、今回はこのlocation-stateがconformに対応したわけなので、conformについても簡単に紹介します。conformはreact-hook-formなどより後発な、Reactのformライブラリです。

https://ja.conform.guide/

主な特徴としては以下が挙げられます。

  • zodなどとの統合が容易
  • 強力なTypeScriptサポート
  • Server ActionsやReactのhooksとの親和性が高い
  • Progressive Enhancementに対応

筆者はconformを、Server Actions時代のformライブラリとして台頭する可能性があると考え、非常に注目しています。以下の記事でより詳細に紹介しているので、conformに馴染みのない方はぜひご覧ください。

https://zenn.dev/akfm/articles/server-actions-with-conform

@location-state/conform

conformでもブラウザバック・フォワード時にちゃんと状態が復元されるようにlocation-stateと統合したのが、今回開発した@location-state/conformです。例によってkoichikさんに監修いただきました。

https://www.npmjs.com/package/@location-state/conform

@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要素でformfieldsを参照することで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>
  );
}

実際にこれで作った画面は以下のようになります。

初期状態
pure conform 0

入力後
pure conform 1

しかし前述の通り、入力後にリロードやブラウザバックを行うと初期状態に戻ってしまいます。

ブラウザバック・フォワード後
pure conform 2

これをブラウザバック・フォワード時に復元されるようにするのが、@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/conformuseLocationFormというhooksを提供しており、formOptionsgetLocationFormPropsを取得できます。前者は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>
  );
}

これだけで、ブラウザバック時にもフォームの状態が復元されるようになります。実際の挙動を確認してみましょう。

入力時

location-state conform 0

ブラウザバック・フォワード後

location-state conform 1

ちゃんと入力してた値が復元されています。もちろん、リロード時にもこの値は復元されます。

動的formの対応

conformは動的にフィールドを追加するようなformにも対応しており、@location-state/conformも同様に動的なformに対応しています。使い方は上記のような静的なformと変わらないですが、exampleに実装があるので必要な方は参考にしてみてください。

https://github.com/recruit-tech/location-state/blob/0bad20cf44c184f6853845aca994ee685b488f9c/apps/example-next-conform/src/app/forms/[storeName]/dynamic-form/form.tsx

感想

開発中、formが空になる体験はやっぱりかなり辛いなぁと改めて感じました。多くの方がブラウザバックのことをあまり気にせず実装していると思うのですが、ユーザーにとってはかなり重要な体験だと思います。特にformでは、住所などの長い情報を入力したのに消えてしまうと再度入力するのがとても億劫になります。こういった体験にストレスを感じたことのある方は多いのではないでしょうか?

この気持ちを減らすべく、location-stateがもっと多くの人に使ってもらえたら嬉しいです。

Discussion