# 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.

Terbit 2026-10-25 · https://ikiform.com/id/guides/feedback-form/react-router

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](https://ikiform.com/id/guides/feedback-form.md#form) 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](https://ikiform.com/id/docs/guides/server.md). 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 |

Diagram: Dua cara kiriman feedback React Router mencapai resource route yang sama

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`:

```text
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`:

```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`:

```bash
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.

Formulir: In-app feedback

- **Bagaimana layar ini bekerja untuk Anda?** (`rating`, rating, wajib)
- **Apa yang bisa membuatnya lebih baik?** (`comment`, textarea)
- **Halaman** (`page`, url)
- **Layar** (`screen`, text)
- **Pengguna** (`user_id`, text)
- **Email** (`email`, email)

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`:

```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`:

```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`:

```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`:

```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`:

```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](https://ikiform.com/id/docs/rate-limits.md) 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

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

| 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

### Bisakah formulir useFetcher mengirim ke resource route?

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.

### Bagaimana cara mendapatkan pengguna yang login di action React Router?

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.

### Apakah ini bekerja di mode SPA React Router?

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.

### Apakah Ikiform gratis?

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.

## Panduan lainnya

- [Formulir feedback untuk website atau aplikasi](https://ikiform.com/id/guides/feedback-form.md): Pasang formulir feedback di halaman mana pun atau di dalam aplikasi Anda: rating, komentar, halaman asalnya, filter spam, dan peringatan saat skornya rendah.
- [Formulir feedback TanStack Start](https://ikiform.com/id/guides/feedback-form/tanstack-start.md): Formulir feedback dalam aplikasi TanStack Start: server function POST dengan middleware sesi, route id sebagai konteks, dan halaman dibaca dari header request.
- [Formulir feedback Next.js](https://ikiform.com/id/guides/feedback-form/nextjs.md): Formulir feedback dalam aplikasi Next.js: server action yang membaca sesi, mengambil halaman dari request, dan meneruskan rating beserta penggunanya.
- [Widget feedback React](https://ikiform.com/id/guides/feedback-form/react-feedback-widget.md): Tombol feedback mengambang untuk aplikasi React yang membuka panel kecil, mengirim layar, versi aplikasi, dan akun bersama setiap jawaban, dan tidak pernah kehilangan draf.
- [Formulir laporan bug](https://ikiform.com/id/guides/feedback-form/bug-report.md): Formulir laporan bug yang menanyakan langkah dan tingkat keparahan, dan membiarkan halaman menambahkan URL, browser, viewport, dan error terbaru dengan sendirinya.
- [Formulir permintaan fitur](https://ikiform.com/id/guides/feedback-form/feature-request.md): Formulir permintaan fitur yang menanyakan masalahnya alih-alih solusinya, menyimpan permintaan per tema, menghitung orang bukan baris, dan mengirim email saat fiturnya rilis.
