ウェブサイトやアプリのフィードバックフォーム

ウェブサイトやアプリのフィードバックフォーム3 分で読めます

React Router でアプリ内フィードバックフォームを作る

React Router 8 のフィードバックフォーム。リソースルートの action が Cookie セッションを読み、レイアウトの fetcher が送信し、JS なしの送信は元のページにリダイレクトします。

Preet SutharIkiform 創業者

ログイン後のルートを包むレイアウトにフィードバックフォームを 1 つマウントし、useFetcher でリソースルートに送信します。その action は Cookie セッションからユーザーを読み、Referer からページを取り、シークレットキーを付けて評価をフォームバックエンドに転送します。JavaScript がまだ読み込まれていないときは、同じ action が生の JSON を返す代わりに元のページへリダイレクトします。

メインガイドの React Router タブでは、公開ページのフォームを fetcher で送信します。ここではフォームはアプリの中、ログインの向こう側にあり、誰が送ったかは action が決めます。キー、許可ドメイン、基本についてはサーバーフレームワークのドキュメントガイドを見てください。コードはフレームワークモードの React Router 8 を対象にしています。

レイアウトの fetcher で変わること

レイアウトにあるフィードバックフォームはすべての画面に同時に存在し、React Router はユーザーが移動してもマウントしたままにします。それが望ましい動作ですが、メインガイドでは答える必要のなかった 4 つの疑問が生まれます。

懸念レイアウトではこのページでの対処
誰が送ったかブラウザーはどのユーザーにでもなりすませるaction が署名付きの Cookie セッションを読む
どの画面かフォームはどの子ルートが表示されているかを知らない各ルートが handle で自分の名前を示し、useMatches で読む
残った状態fetcher の data はナビゲーション後も残るkey オプションで画面ごとに 1 つの fetcher
JavaScript がまだないfetcher.Form は本物のフォームなので、ブラウザーがリソースルートに送信するaction がドキュメントの送信を見分けて元に戻す
React Router のフィードバック送信が同じリソースルートに届く 2 つの経路fetcher.Formアプリのレイアウト内api/feedbackの actionセッション、Referer、画面Ikiformシークレットキー、保存fetcher.dataお礼かメッセージ元のページへリダイレクト同じページに?feedback=sentfetch+ ユーザー、ページdata()Sec-Fetch-Mode: navigateReact Router のフィードバック送信が同じリソースルートに届く 2 つの経路fetcher.Formアプリのレイアウト内api/feedback のactionセッション、Referer、画面Ikiformシークレットキー、保存fetcher.dataお礼かメッセージ元のページへリダイレクト同じページに?feedback=sentfetch+ ユーザー、ページdata()Sec-Fetch-Mode: navigate
  1. ユーザーは、ログイン後のすべてのルートでレイアウトが描画するフォームで画面を評価します。
  2. ハイドレーション後は、fetcher がバックグラウンドで送信します。action は Cookie セッション、Referer のページ、画面名を読みます。
  3. action はシークレットキーとユーザーを付けて Ikiform に送信します。
  4. 結果は data() で戻り、ナビゲーションなしでその場に表示されます。
  5. ハイドレーション前は、ブラウザーがフォームをドキュメントとして送信します。action はナビゲーションを検出し、結果をクエリに入れて送信元のページにリダイレクトします。
1 つの action に 2 種類の呼び出し元。リダイレクトを受け取るのはブラウザーのナビゲーションだけで、fetcher の呼び出しはデータを受け取ります。

プロジェクトの準備

create-react-router で作ったプロジェクトで関わるファイル:

プロジェクトの構成
app/
  routes.ts
  sessions.server.ts
  components/
    feedback-form.tsx
  routes/
    api.feedback.ts
    app-layout.tsx
    project-settings.tsx

action 用のリソースルートと、ログイン後の画面用のパスなしレイアウトを追加します。action があってデフォルトエクスポートのないモジュールはリソースルートなので、何も描画しません。

app/routes.ts
import { layout, route, type RouteConfig } from "@react-router/dev/routes";

export default [
  route("api/feedback", "routes/api.feedback.ts"),
  layout("routes/app-layout.tsx", [route("projects/:id/settings", "routes/project-settings.tsx")]),
] satisfies RouteConfig;

API keys で submissions:write スコープのシークレットキーを作成します。サーバーは両方の値を process.env から読みます。サーバーが動く場所で設定し、キーには絶対に VITE_ の接頭辞を付けないでください。付けるとクライアントのバンドルに入ってしまいます。

.env
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
SESSION_SECRET=a-long-random-string

下の定義をフォームの Fields タブに貼り付けます。本人が入力するのは 2 つのフィールドで、残りは action が用意します。セッションの email によって、各通知の Reply が本人に届きます。

プレビュー

Ikiform のバリデーターをブラウザーで実行します。何も送信されません。

この画面の使い心地はどうですか?

リクエストからサーバーが設定します。

rating に 9 を送るか、定義にないフィールドを送ると、action が受け取る 422 を確認できます。

作ってみる

Cookie セッションからユーザーを読む

createCookieSessionStorage は secrets で Cookie に署名するので、ユーザーは中身を読めても変更はできません。ログイン処理がすでにセッションを書き込んでいるなら、そのストレージを再利用して readUser を追加してください。

app/sessions.server.ts
import { createCookieSessionStorage } from "react-router";

interface SessionData {
  userId: string;
  email: string;
}

export const { getSession, commitSession, destroySession } = createCookieSessionStorage<SessionData>({
  cookie: {
    name: "__session",
    httpOnly: true,
    path: "/",
    sameSite: "lax",
    secrets: [process.env.SESSION_SECRET ?? ""],
    secure: process.env.NODE_ENV === "production",
  },
});

export async function readUser(request: Request) {
  const session = await getSession(request.headers.get("Cookie"));
  const id = session.get("userId");
  const email = session.get("email");
  return id && email ? { id, email } : null;
}

.server の接尾辞で、このモジュールをクライアントのバンドルから除外します。コンポーネントからインポートするとビルドが失敗します。

リソースルート

app/routes/api.feedback.ts
import { data, redirect } from "react-router";
import { readUser } from "~/sessions.server";
import type { Route } from "./+types/api.feedback";

const SCREEN = /^[a-z0-9/._-]{1,120}$/;

function samePage(referer: string | null, origin: string) {
  try {
    const url = new URL(referer ?? "");
    return url.origin === origin ? url.pathname : "";
  } catch {
    return "";
  }
}

export async function action({ request }: Route.ActionArgs) {
  const form = await request.formData();
  const origin = new URL(request.url).origin;
  const path = samePage(request.headers.get("Referer"), origin);
  const native = request.headers.get("Sec-Fetch-Mode") === "navigate";

  function reply(status: "sent" | "error", message: string, code = 200) {
    if (native) return redirect(`${path || "/"}?feedback=${status}`);
    return data({ status, message }, { status: code });
  }

  const user = await readUser(request);
  if (!user) return reply("error", "You were signed out. Sign in, then send it again.", 401);

  const rating = Number(form.get("rating"));
  if (!Number.isInteger(rating) || rating < 1 || rating > 5) return reply("error", "Choose a score first.", 400);
  const screen = String(form.get("screen") ?? "");

  let response: Response;
  try {
    response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
        Origin: origin,
      },
      body: JSON.stringify({
        rating,
        comment: String(form.get("comment") ?? "").slice(0, 4000),
        page: path ? origin + path : "",
        screen: SCREEN.test(screen) ? screen : "unknown",
        user_id: user.id,
        email: user.email,
        _gotcha: String(form.get("_gotcha") ?? ""),
      }),
      signal: AbortSignal.timeout(8000),
    });
  } catch {
    return reply("error", "Feedback is unreachable right now. Try again shortly.", 502);
  }

  const result = await response.json().catch(() => null);
  if (result?.ok) return reply("sent", "Got it. Thank you.");
  return reply("error", result?.error?.fields?.comment?.[0] ?? result?.error?.message ?? "That didn't go through.", 502);
}
  • samePage は、Referer が自分のオリジンのときだけパスを残し、クエリ文字列は捨てます。アプリの URL では、クエリに ID やトークンが入っているからです。
  • ブラウザー自身がページの読み込みとしてフォームを送信すると、Sec-Fetch-Mode は navigate になります。fetcher はバックグラウンドのリクエストとして送るので、fetcher には data() が、それ以外にはリダイレクトが返ります。
  • エラーは throw せずに、4xx か 5xx の data() で返します。throw したエラーは最も近い ErrorBoundary まで伝わり、ユーザーが作業していた画面を置き換えてしまいます。
  • Origin にはリクエスト自身のオリジンを設定します。react-router dev では http://localhost:5173、本番では自分のドメインです。

フォーム

app/components/feedback-form.tsx
import { useFetcher, useMatches, useSearchParams } from "react-router";
import type { action } from "~/routes/api.feedback";

const SCORES = ["Blocked", "Painful", "Okay", "Smooth", "Excellent"];

function useScreen() {
  const named = useMatches().flatMap((match) => {
    const screen = (match.handle as { screen?: string } | undefined)?.screen;
    return screen ? [screen] : [];
  });
  return named.at(-1) ?? "unknown";
}

export function FeedbackForm() {
  const screen = useScreen();
  const fetcher = useFetcher<typeof action>({ key: `feedback:${screen}` });
  const [params] = useSearchParams();
  const busy = fetcher.state !== "idle";
  const fallback = params.get("feedback");
  const sent = fetcher.data?.status === "sent" || fallback === "sent";
  const error = fetcher.data?.status === "error" ? fetcher.data.message : fallback === "error" ? "That didn't go through." : "";

  if (sent) return <p role="status">Got it. Thank you.</p>;

  return (
    <fetcher.Form method="post" action="/api/feedback" aria-busy={busy}>
      <fieldset>
        <legend>How is this screen working for you?</legend>
        {SCORES.map((label, index) => (
          <label key={label}>
            <input type="radio" name="rating" value={index + 1} required /> {label}
          </label>
        ))}
      </fieldset>
      <label htmlFor="feedback-comment">What would make it better?</label>
      <textarea id="feedback-comment" name="comment" rows={3} maxLength={4000} />
      <input type="hidden" name="screen" value={screen} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {error ? <p role="alert">{error}</p> : null}
      <button disabled={busy}>{busy ? "Sending" : "Send feedback"}</button>
    </fetcher.Form>
  );
}
  • useScreen はマッチしたルートをたどり、いちばん深い handle.screen を使うので、ネストしたルートは親より細かく自分の名前を付けられます。
  • fetcher の key には画面名が含まれます。ある画面で送信したあと、次の画面では前のお礼ではなく新しいフォームが表示されます。
  • マッチしたローダーが再検証される間、fetcher.state は submitting、続いて loading になります。どちらも処理中として扱うので、結果が届くまでボタンは無効のままです。
  • JavaScript がなければ、リダイレクトで ?feedback=sent 付きの同じページに戻り、useSearchParams が同じお礼の一文を表示します。

1 回だけマウントする

app/routes/app-layout.tsx
import { Outlet } from "react-router";
import { FeedbackForm } from "~/components/feedback-form";

export default function AppLayout() {
  return (
    <>
      <Outlet />
      <aside aria-label="Feedback">
        <FeedbackForm />
      </aside>
    </>
  );
}
app/routes/project-settings.tsx
export const handle = { screen: "projects/settings" };

export default function ProjectSettings() {
  return <h1>Project settings</h1>;
}

どのプロジェクトの設定画面も projects/settings として記録されるので、まとめて平均されます。実際の ID を含む具体的なパスは page に残ります。

ハニーポット、人間確認、制限

_gotcha 入力欄は action をそのまま通り、埋まったものはスパムとして保存されます。シークレットキー付きの送信では Ikiform の人間確認はスキップされ、代わりにセッションのチェックが働きます。署名付き Cookie のない訪問者からは何も送信されません。残る注意点は 1 つのアカウントがループで送信することで、Ikiform はそれらをすべてサーバーの IP に対して数えます。

サーバー側の版はシークレットキー付きで送信するので、Ikiform は Allowed domains と人間確認をスキップします。その場合はすべての送信がサーバーの IP から来るため、訪問者ごとに 1 分あたり 5 件という Standard の制限が、全訪問者をまとめて数えることになります。サーバー経由で送信するフォームには Relaxed か Custom のレート制限を選び、IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。

SSR、SPA モード、プリレンダー

action にはサーバーが必要です。react-router.config.ts で ssr: false にするとビルドにサーバーがなくなり、サーバーの action は動きません。信頼できる本人情報のないまま、ブラウザーから送信する clientAction に戻ることになります。マーケティング用のページをプリレンダーするのは問題ありません。action は別のルートで、リクエストごとに実行されるからです。サインイン後のアプリ部分は ssr: true のままにしてください。

デプロイ

ホスト対処法
Vercel@vercel/react-router をインストールし、react-router.config.ts の presets に vercelPreset() を追加します。両方の変数をプロジェクトの設定に追加します。
NetlifyVite のプラグインに @netlify/vite-plugin-react-router を追加します。action は Netlify Functions として動きます。両方の変数をサイトの環境変数の設定に設定します。
Cloudflare@cloudflare/vite-plugin を使う Cloudflare の React Router テンプレートを使います。バインディングは import { env } from "cloudflare:workers" で読みます。React Router 8 では action の context に cloudflare.env が含まれなくなりました。キーはそこから読み、Worker のシークレットとして保存し、ローカル実行用には .dev.vars を使います。

localhost からの無料テスト

Ikiform は、プランのないワークスペースに localhost からの無料のテスト送信を月 50 件提供し、Origin ヘッダーでそれを判定します。action からの fetch には、自分で付けない限り Origin がありません。オリジンのない送信は本番のサーバー送信として扱われ、402 plan_required となり、プランを選ぶまで保留されます。リクエストのオリジンを渡せば、react-router dev からの送信がローカルテストとして数えられます。

動作を確認する

  1. react-router dev を実行してサインインし、プロジェクトの設定を開いて Painful を選び、コメントを追加します。
  2. Submissions を確認します。screen は projects/settings、page にはクエリ文字列なしの実際の ID が入り、user_id と email が自分のアカウントと一致しています。
  3. 別の画面に移動します。そこのフォームは空で、前のお礼は表示されていません。
  4. JavaScript をオフにしてもう一度送信します。ページが ?feedback=sent 付きで再読み込みされ、お礼の一文が表示されます。
  5. セッションの Cookie を消して送信します。フォームにサインインを促すメッセージが表示されます。

ハマりどころ

問題対処法
送信後にブラウザーに生の JSON が表示される送信がナビゲーションとして届き、action が data() を返しています。Sec-Fetch-Mode の分岐と、サーバーの手前で何かがそのヘッダーを削除していないかを確認してください。
ローカルでのテスト中に 402 plan_required になる送信にローカルのオリジンがありませんでした。action の Origin ヘッダーを残し、localhost でアプリを開いてください。
screen が常に unknown になるマッチしたルートのどれも screen 付きの handle をエクスポートしていないか、値に大文字やスペースが含まれています。小文字でパスのような名前を使ってください。
送信に失敗するとエラーバウンダリーがページを置き換えるaction の中で何かが throw しています。想定される失敗には、すべてステータス付きの data() を返してください。
サインイン直後に readUser が null を返すCookie を書いたコードとこのコードで SESSION_SECRET が違うため、署名の確認に失敗しています。両方で同じストレージモジュールを使ってください。

よくある質問

手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com