ウェブサイトやアプリのフィードバックフォーム3 分で読めます
React Router でアプリ内フィードバックフォームを作る
React Router 8 のフィードバックフォーム。リソースルートの action が Cookie セッションを読み、レイアウトの fetcher が送信し、JS なしの送信は元のページにリダイレクトします。
ログイン後のルートを包むレイアウトにフィードバックフォームを 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 がドキュメントの送信を見分けて元に戻す |
- ユーザーは、ログイン後のすべてのルートでレイアウトが描画するフォームで画面を評価します。
- ハイドレーション後は、fetcher がバックグラウンドで送信します。action は Cookie セッション、Referer のページ、画面名を読みます。
- action はシークレットキーとユーザーを付けて Ikiform に送信します。
- 結果は data() で戻り、ナビゲーションなしでその場に表示されます。
- ハイドレーション前は、ブラウザーがフォームをドキュメントとして送信します。action はナビゲーションを検出し、結果をクエリに入れて送信元のページにリダイレクトします。
プロジェクトの準備
create-react-router で作ったプロジェクトで関わるファイル:
app/
routes.ts
sessions.server.ts
components/
feedback-form.tsx
routes/
api.feedback.ts
app-layout.tsx
project-settings.tsxaction 用のリソースルートと、ログイン後の画面用のパスなしレイアウトを追加します。action があってデフォルトエクスポートのないモジュールはリソースルートなので、何も描画しません。
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_ の接頭辞を付けないでください。付けるとクライアントのバンドルに入ってしまいます。
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 を追加してください。
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 の接尾辞で、このモジュールをクライアントのバンドルから除外します。コンポーネントからインポートするとビルドが失敗します。
リソースルート
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、本番では自分のドメインです。
フォーム
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 回だけマウントする
import { Outlet } from "react-router";
import { FeedbackForm } from "~/components/feedback-form";
export default function AppLayout() {
return (
<>
<Outlet />
<aside aria-label="Feedback">
<FeedbackForm />
</aside>
</>
);
}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() を追加します。両方の変数をプロジェクトの設定に追加します。 |
| Netlify | Vite のプラグインに @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 からの送信がローカルテストとして数えられます。
動作を確認する
react-router devを実行してサインインし、プロジェクトの設定を開いて Painful を選び、コメントを追加します。- Submissions を確認します。
screenはprojects/settings、pageにはクエリ文字列なしの実際の ID が入り、user_idとemailが自分のアカウントと一致しています。 - 別の画面に移動します。そこのフォームは空で、前のお礼は表示されていません。
- JavaScript をオフにしてもう一度送信します。ページが
?feedback=sent付きで再読み込みされ、お礼の一文が表示されます。 - セッションの 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 が違うため、署名の確認に失敗しています。両方で同じストレージモジュールを使ってください。 |
よくある質問
はい。fetcher.Form の action にリソースルートを指定します。fetcher が結果を受け取れるよう action から data() を返し、リクエストがブラウザーのナビゲーションのときはリダイレクトして、ハイドレーション前の送信で生の JSON が表示されないようにします。
セッションストレージの getSession 関数で Cookie ヘッダーからセッションを読み、そこからユーザーの ID を読みます。フォームから送られたユーザー ID は決して信用しないでください。
いいえ。ssr を false にすると、action を実行するサーバーがありません。clientAction から送信することはできますが、その場合は本人情報をブラウザーが渡すことになり、信用できません。
いいえ、無料プランはありません。テストは無料で、支払う前に localhost から月 50 件のテスト送信ができます。本番運用するには、月 $6 で 500 件からの Pro か、1 回払いで 10,000 件 $49 または 50,000 件 $149 のパックを選べます。クレジットに有効期限はありません。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com