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.
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:
| Persoalan | Di layout | Cara halaman ini menanganinya |
|---|---|---|
| Siapa pengirimnya | Browser bisa mengaku sebagai pengguna mana pun | Action membaca sesi cookie yang ditandatangani |
| Layar yang mana | Formulir tidak tahu child route mana yang sedang tampil | Setiap route menamai dirinya di handle, dibaca dengan useMatches |
| State yang tertinggal | data milik fetcher bertahan melewati navigasi | Satu fetcher per layar lewat opsi key |
| Belum ada JavaScript | fetcher.Form adalah formulir sungguhan, jadi browser mengirimnya ke resource route | Action mengenali kiriman dokumen dan me-redirect kembali |
- Pengguna menilai layar di formulir yang dirender layout di setiap route untuk pengguna login.
- Setelah hydration, fetcher mengirim di latar belakang. Action membaca sesi cookie, halaman dari Referer, dan nama layar.
- Action mengirim ke Ikiform dengan secret key dan pengguna terlampir.
- Hasilnya kembali lewat data() dan tampil di tempat, tanpa navigasi.
- Sebelum hydration, browser mengirim formulir sebagai dokumen. Action melihat navigasi dan me-redirect ke halaman asalnya dengan hasil di query.
Setup proyek
File-file yang terlibat, dalam proyek yang dibuat dengan create-react-router:
app/
routes.ts
sessions.server.ts
components/
feedback-form.tsx
routes/
api.feedback.ts
app-layout.tsx
project-settings.tsxTambahkan 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.
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.
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
SESSION_SECRET=a-long-random-stringTempel 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.
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.
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
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);
}samePagehanya menyimpan path jikaRefererberasal dari origin Anda sendiri, dan membuang query string, tempat URL aplikasi membawa ID dan token.Sec-Fetch-Modebernilainavigatesaat browser sendiri mengirim formulir sebagai pemuatan halaman. Fetcher mengirimnya sebagai request latar belakang, jadi fetcher mendapatdata()dan selebihnya mendapat redirect.- Error memakai
data()4xx atau 5xx alih-alih throw. Error yang dilempar akan naik keErrorBoundaryterdekat dan menggantikan layar tempat pengguna sedang bekerja. Origindiatur ke origin request itu sendiri. Nilainyahttp://localhost:5173di bawahreact-router dev, dan domain Anda di produksi.
Formulirnya
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>
);
}useScreenmenelusuri route yang cocok dan menyimpanhandle.screenterdalam, sehingga route bersarang bisa menamai dirinya lebih tepat daripada induknya.keyfetcher menyertakan layar. Setelah mengirim di satu layar, layar berikutnya menampilkan formulir baru alih-alih ucapan terima kasih sebelumnya.fetcher.statebernilaisubmittinglaluloadingselama loader yang cocok melakukan revalidasi. Keduanya dihitung sibuk, jadi tombol tetap nonaktif sampai jawabannya masuk.- Tanpa JavaScript, redirect kembali ke halaman dengan
?feedback=sent, danuseSearchParamsmenampilkan baris terima kasih yang sama.
Mount sekali saja
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>;
}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
| Host | Yang harus dilakukan |
|---|---|
| Vercel | Instal @vercel/react-router dan tambahkan vercelPreset() ke presets di react-router.config.ts. Tambahkan kedua variabel di pengaturan proyek. |
| Netlify | Tambahkan @netlify/vite-plugin-react-router ke plugin Vite. Action berjalan sebagai Netlify Functions; atur kedua variabel di pengaturan environment situs. |
| Cloudflare | Gunakan 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
- Jalankan
react-router dev, login, buka pengaturan sebuah proyek, pilih Painful, dan tambahkan komentar. - Periksa Submissions:
screenbernilaiprojects/settings,pageberisi ID asli tanpa query string, sertauser_iddanemailcocok dengan akun Anda. - Pindah ke layar lain. Formulir di sana kosong, tidak menampilkan ucapan terima kasih sebelumnya.
- Matikan JavaScript dan kirim lagi. Halaman dimuat ulang dengan
?feedback=sentdan baris terima kasih. - Hapus cookie sesi lalu kirim. Formulir menampilkan pesan login.
Hal yang sering menjebak
| Masalah | Yang harus dilakukan |
|---|---|
| Browser menampilkan JSON mentah setelah submit | Kiriman 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 lokal | Kiriman tidak punya origin lokal. Pertahankan header Origin di action dan buka aplikasi di localhost. |
screen selalu unknown | Tidak 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 gagal | Sesuatu di action melempar exception. Kembalikan data() dengan status untuk setiap kegagalan yang sudah diperkirakan. |
readUser mengembalikan null tepat setelah login | SESSION_SECRET berbeda antara kode yang menulis cookie dan kode ini, sehingga tanda tangannya gagal. Gunakan satu modul storage untuk keduanya. |
Pertanyaan
Bisa. Beri fetcher.Form action yang mengarah ke resource route. Kembalikan data() dari action agar fetcher mendapat hasilnya, dan redirect saat request-nya adalah navigasi browser agar kiriman sebelum hydration tidak menampilkan JSON mentah.
Baca sesi dari header Cookie dengan fungsi getSession dari session storage Anda, lalu baca ID pengguna darinya. Jangan pernah memercayai ID pengguna yang dikirim formulir.
Tidak. Dengan ssr diatur ke false, tidak ada server untuk menjalankan action. Anda bisa mengirim dari clientAction, tetapi kemudian browser yang menyediakan identitas dan itu tidak bisa dipercaya.
Tidak, tidak ada paket gratis. Pengujian gratis: Anda bisa mengirim 50 kiriman uji per bulan dari localhost sebelum membayar. Untuk go live, Pro mulai $6/bln untuk 500 kiriman, atau Anda bisa bayar sekali untuk 10.000 kiriman seharga $49 atau 50.000 kiriman seharga $149, dengan kredit yang tidak pernah kedaluwarsa.
Mentok di salah satu langkah, atau ingin panduan untuk situasi Anda? Email hello@ikiform.com.