Formulir feedback untuk website atau aplikasi

Formulir feedback untuk website atau aplikasi7 menit baca

Buat formulir feedback dalam aplikasi dengan React Router

Formulir feedback React Router 8: action di resource route membaca sesi cookie, fetcher mengirim dari layout, dan kiriman tanpa JS di-redirect kembali.

Preet SutharPendiri, Ikiform

Mount satu formulir feedback di layout yang membungkus route untuk pengguna login dan kirim dengan useFetcher ke resource route. Action-nya membaca pengguna dari sesi cookie Anda, mengambil halaman dari Referer, dan meneruskan rating ke backend formulir Anda dengan secret key. Saat JavaScript belum termuat, action yang sama me-redirect kembali ke halaman alih-alih menjawab dengan JSON mentah.

Tab React Router di panduan utama mengirim formulir halaman publik lewat fetcher. Di sini formulir ada di dalam aplikasi Anda, di balik login, dan action yang menentukan siapa pengirimnya. Untuk key, allowed domains, dan dasar-dasarnya, lihat panduan dokumentasi framework server. Kodenya menargetkan React Router 8 dalam framework mode.

Apa yang diubah fetcher di layout

Formulir feedback di layout ada di setiap layar sekaligus, dan React Router menjaganya tetap ter-mount saat pengguna bernavigasi. Itulah yang Anda inginkan, dan hal itu memunculkan empat pertanyaan yang tidak perlu dijawab panduan utama:

PersoalanDi layoutCara halaman ini menanganinya
Siapa pengirimnyaBrowser bisa mengaku sebagai pengguna mana punAction membaca sesi cookie yang ditandatangani
Layar yang manaFormulir tidak tahu child route mana yang sedang tampilSetiap route menamai dirinya di handle, dibaca dengan useMatches
State yang tertinggaldata milik fetcher bertahan melewati navigasiSatu fetcher per layar lewat opsi key
Belum ada JavaScriptfetcher.Form adalah formulir sungguhan, jadi browser mengirimnya ke resource routeAction mengenali kiriman dokumen dan me-redirect kembali
Dua cara kiriman feedback React Router mencapai resource route yang samafetcher.Formdi layoutaplikasiactionapi/feedbacksesi,Referer,layarIkiformsecret key,menyimpannyafetcher.dataterimakasih ataupesannyaredirectkembali?feedback=sentdi halamanyang samafetch+ pengguna, halamandata()Sec-Fetch-Mode: navigateDua cara kiriman feedback React Router mencapai resource route yang samafetcher.Formdi layout aplikasiactionapi/feedbacksesi, Referer, layarIkiformsecret key,menyimpannyafetcher.dataterima kasih ataupesannyaredirectkembali?feedback=sentdi halamanyang samafetch+ pengguna, halamandata()Sec-Fetch-Mode: navigate
  1. Pengguna menilai layar di formulir yang dirender layout di setiap route untuk pengguna login.
  2. Setelah hydration, fetcher mengirim di latar belakang. Action membaca sesi cookie, halaman dari Referer, dan nama layar.
  3. Action mengirim ke Ikiform dengan secret key dan pengguna terlampir.
  4. Hasilnya kembali lewat data() dan tampil di tempat, tanpa navigasi.
  5. Sebelum hydration, browser mengirim formulir sebagai dokumen. Action melihat navigasi dan me-redirect ke halaman asalnya dengan hasil di query.
Satu action, dua jenis pemanggil. Hanya navigasi browser yang mendapat redirect; panggilan fetcher mendapat data.

Setup proyek

File-file yang terlibat, dalam proyek yang dibuat dengan create-react-router:

Struktur proyek
app/
  routes.ts
  sessions.server.ts
  components/
    feedback-form.tsx
  routes/
    api.feedback.ts
    app-layout.tsx
    project-settings.tsx

Tambahkan resource route untuk action dan layout tanpa path untuk layar pengguna login. Modul dengan action tanpa default export adalah resource route, jadi tidak merender apa pun.

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;

Buat secret key di API keys dengan scope submissions:write. Server membaca kedua nilai dari process.env; atur di tempat server Anda berjalan, dan jangan pernah memberi key awalan VITE_, yang akan memasukkannya ke bundle klien.

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

Tempel definisi di bawah di tab Fields formulir. Orang mengetik dua field; action menyediakan sisanya, dan email dari sesi membuat Reply di setiap notifikasi sampai ke mereka.

Pratinjau

Menjalankan validator Ikiform di browser Anda. Tidak ada yang dikirim.

Bagaimana layar ini bekerja untuk Anda?

Diatur oleh server Anda dari request.

Kirim rating bernilai 9 atau field yang tidak ada di definisi, dan Anda akan melihat 422 yang diterima action Anda.

Bangun

Baca pengguna dari sesi cookie

createCookieSessionStorage menandatangani cookie dengan secrets, jadi pengguna bisa membacanya tetapi tidak bisa mengubahnya. Jika proses login Anda sudah menulis sesi, pakai ulang storage-nya dan tambahkan 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;
}

Akhiran .server menjauhkan modul dari bundle klien; meng-import-nya dari komponen akan menggagalkan build.

Resource route

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 hanya menyimpan path jika Referer berasal dari origin Anda sendiri, dan membuang query string, tempat URL aplikasi membawa ID dan token.
  • Sec-Fetch-Mode bernilai navigate saat browser sendiri mengirim formulir sebagai pemuatan halaman. Fetcher mengirimnya sebagai request latar belakang, jadi fetcher mendapat data() dan selebihnya mendapat redirect.
  • Error memakai data() 4xx atau 5xx alih-alih throw. Error yang dilempar akan naik ke ErrorBoundary terdekat dan menggantikan layar tempat pengguna sedang bekerja.
  • Origin diatur ke origin request itu sendiri. Nilainya http://localhost:5173 di bawah react-router dev, dan domain Anda di produksi.

Formulirnya

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 menelusuri route yang cocok dan menyimpan handle.screen terdalam, sehingga route bersarang bisa menamai dirinya lebih tepat daripada induknya.
  • key fetcher menyertakan layar. Setelah mengirim di satu layar, layar berikutnya menampilkan formulir baru alih-alih ucapan terima kasih sebelumnya.
  • fetcher.state bernilai submitting lalu loading selama loader yang cocok melakukan revalidasi. Keduanya dihitung sibuk, jadi tombol tetap nonaktif sampai jawabannya masuk.
  • Tanpa JavaScript, redirect kembali ke halaman dengan ?feedback=sent, dan useSearchParams menampilkan baris terima kasih yang sama.

Mount sekali saja

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>;
}

Layar pengaturan setiap proyek dilaporkan sebagai projects/settings, sehingga rata-ratanya digabung; path konkret dengan ID aslinya tetap ada di page.

Honeypot, pemeriksaan manusia, dan batas

Input _gotcha melewati action tanpa diubah, dan yang terisi disimpan sebagai spam. Pemeriksaan manusia Ikiform dilewati untuk kiriman dengan secret key, dan pemeriksaan sesi menggantikannya: pengunjung tanpa cookie bertanda tangan tidak mengirim apa pun. Yang tersisa untuk diawasi adalah satu akun yang mengirim terus-menerus, dan Ikiform menghitung semuanya terhadap IP server Anda.

Versi sisi server mengirim dengan secret key, sehingga Ikiform melewati Allowed domains dan pemeriksaan manusianya. Semua kiriman lalu datang dari IP server Anda, dan batas Standard 5 per menit per pengunjung menghitung semua pengunjung Anda sebagai satu. Pilih rate limit Relaxed atau Custom untuk formulir yang Anda kirim lewat server, dan simpan IKIFORM_SECRET_KEY di environment variable khusus server.

SSR, mode SPA, dan pre-rendering

Action butuh server. Dengan ssr: false di react-router.config.ts, build tidak punya server, dan server action tidak bisa berjalan; Anda kembali ke clientAction yang mengirim dari browser tanpa identitas tepercaya. Pre-rendering halaman marketing tidak masalah, karena action adalah route terpisah yang tetap berjalan per request. Pertahankan ssr: true untuk bagian aplikasi yang butuh login.

Deploy

HostYang harus dilakukan
VercelInstal @vercel/react-router dan tambahkan vercelPreset() ke presets di react-router.config.ts. Tambahkan kedua variabel di pengaturan proyek.
NetlifyTambahkan @netlify/vite-plugin-react-router ke plugin Vite. Action berjalan sebagai Netlify Functions; atur kedua variabel di pengaturan environment situs.
CloudflareGunakan template React Router dari Cloudflare dengan @cloudflare/vite-plugin. Baca binding dengan import { env } from "cloudflare:workers"; di React Router 8, context milik action tidak lagi membawa cloudflare.env. Baca key di sana, dan simpan sebagai secret Worker, dengan .dev.vars untuk menjalankan secara lokal.

Uji gratis dari localhost

Ikiform memberi workspace tanpa paket 50 kiriman uji gratis per bulan dari localhost, dinilai dari header Origin. fetch dari action Anda tidak punya Origin kecuali Anda menambahkannya, dan kiriman tanpa origin diperlakukan sebagai kiriman server live: 402 plan_required, ditahan sampai Anda memilih paket. Meneruskan origin request membuat react-router dev dihitung sebagai uji lokal.

Pastikan berfungsi

  1. Jalankan react-router dev, login, buka pengaturan sebuah proyek, pilih Painful, dan tambahkan komentar.
  2. Periksa Submissions: screen bernilai projects/settings, page berisi ID asli tanpa query string, serta user_id dan email cocok dengan akun Anda.
  3. Pindah ke layar lain. Formulir di sana kosong, tidak menampilkan ucapan terima kasih sebelumnya.
  4. Matikan JavaScript dan kirim lagi. Halaman dimuat ulang dengan ?feedback=sent dan baris terima kasih.
  5. Hapus cookie sesi lalu kirim. Formulir menampilkan pesan login.

Hal yang sering menjebak

MasalahYang harus dilakukan
Browser menampilkan JSON mentah setelah submitKiriman datang sebagai navigasi dan action mengembalikan data(). Periksa cabang Sec-Fetch-Mode, dan pastikan tidak ada yang membuang header itu di depan server Anda.
402 plan_required saat menguji secara lokalKiriman tidak punya origin lokal. Pertahankan header Origin di action dan buka aplikasi di localhost.
screen selalu unknownTidak ada route yang cocok yang mengekspor handle dengan screen, atau nilainya mengandung huruf kapital atau spasi. Gunakan nama huruf kecil yang mirip path.
Error boundary menggantikan halaman saat pengiriman gagalSesuatu di action melempar exception. Kembalikan data() dengan status untuk setiap kegagalan yang sudah diperkirakan.
readUser mengembalikan null tepat setelah loginSESSION_SECRET berbeda antara kode yang menulis cookie dan kode ini, sehingga tanda tangannya gagal. Gunakan satu modul storage untuk keduanya.

Pertanyaan

Mentok di salah satu langkah, atau ingin panduan untuk situasi Anda? Email hello@ikiform.com.