# Buat widget feedback mengambang untuk aplikasi React Anda

> Tombol feedback mengambang untuk aplikasi React yang membuka panel kecil, mengirim layar, versi aplikasi, dan akun bersama setiap jawaban, dan tidak pernah kehilangan draf.

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

Render tombol fixed yang membuka panel berbasis Popover API browser, sehingga panel berada di atas setiap lapisan aplikasi Anda tanpa perang z-index dan tertutup saat Escape ditekan. Saat submit, tambahkan route saat ini, versi aplikasi, ID akun, dan viewport ke data formulir, lalu kirim dari browser dengan Ikiform SDK. Kirim email pengguna hanya jika mereka mencentang kotak yang meminta balasan.

## Apa yang berbeda dari widget dalam aplikasi

Formulir di halaman menunggu orang menggulir ke sana. Widget ada di setiap layar, yang mengubah apa saja yang bisa salah. Widget harus berada di atas modal, menu, dan toast Anda sendiri. Widget tidak boleh kehilangan pesan yang setengah ditulis saat seseorang mengklik di luarnya. Dan karena pengguna sudah login, Anda sudah tahu siapa mereka dan di mana mereka berada, jadi widget sebaiknya mengirim informasi itu alih-alih menanyakannya.

Diagram: Perjalanan satu sesi widget feedback, dari klik hingga jawaban tersimpan

1. Tombol selalu ada di layar, menempel di pojok kanan bawah.
2. Mengkliknya membuka panel di top layer browser dan memulai attempt key baru.
3. Pengguna memilih wajah dan menulis catatan. State formulir tetap ter-mount selama panel terbuka maupun tersembunyi.
4. Saat submit, widget menambahkan route, versi aplikasi, ID akun, dan viewport, lalu mengirim dengan SDK.
5. Ikiform menyimpannya sekali, bahkan jika tombol ditekan dua kali, karena kedua kiriman membawa idempotency key yang sama.
6. Jika pengguna mengklik di luar atau menekan Escape lebih dulu, panel tersembunyi dan draf masih ada di kesempatan berikutnya.

Light dismiss adalah perilaku default Popover API. Karena panel hanya disembunyikan, field teks tetap menyimpan apa yang sudah diketik.

## Field untuk aplikasi

Formulir: In-app feedback

- **Bagaimana pengalaman Anda?** (`rating`, rating, wajib)
- **Ceritakan lebih lanjut** (`message`, textarea)
- **Tim boleh membalas saya lewat email** (`may_reply`, checkbox)
- **Email** (`email`, email)
- **Layar** (`route`, text)
- **Versi aplikasi** (`app_version`, text)
- **Akun** (`account_id`, text)
- **Viewport** (`viewport`, text)

Pengguna hanya melihat tiga field pertama. Sisanya ditambahkan lewat kode saat submit. `may_reply` adalah checkbox, disimpan sebagai `true` atau `false`.

| Field | Berasal dari | Alasan |
| --- | --- | --- |
| `rating` | Pengguna, lima wajah | Field `rating` dengan `max: 5`. Wajah lebih cepat dipilih daripada angka |
| `message` | Pengguna | Opsional. Paling berguna saat wajahnya buruk |
| `may_reply`, `email` | Checkbox, lalu sesi Anda | Email hanya dikirim jika mereka meminta balasan, dan tombol Balas di notifikasi menuju ke mereka |
| `route` | `window.location.pathname` | Layar yang mana. Dibaca saat submit, setelah navigasi sisi klien apa pun |
| `app_version` | Build Anda | Memberi tahu apakah keluhan menyangkut kode yang sudah Anda ubah |
| `account_id` | Sesi Anda | Memungkinkan Anda melihat paket dan data mereka. Bukan bukti identitas, lihat di bawah |
| `viewport` | `window.innerWidth` dan `innerHeight` | Keluhan tata letak sering bergantung pada ukuran |

## Komponennya

Komponen ini butuh `@ikiform/sdk` dan React 19, yang punya tipe untuk `popover`, `popoverTarget`, dan event `toggle`. `popoverTarget` pada tombol mengikatnya ke panel, sehingga browser menangani pembukaan, Escape, dan pengembalian fokus ke tombol.

`components/feedback-widget.tsx`:

```tsx
import { submit } from "@ikiform/sdk";
import { type FormEvent, type ToggleEvent, useId, useRef, useState } from "react";
import "./feedback-widget.css";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";
const FACES = ["Awful", "Bad", "Okay", "Good", "Great"];

interface Props {
  accountId: string;
  email: string;
  appVersion: string;
}

export function FeedbackWidget({ accountId, email, appVersion }: Props) {
  const panelId = useId();
  const attempt = useRef("");
  const [status, setStatus] = useState<"idle" | "sending" | "sent">("idle");
  const [error, setError] = useState("");

  function onToggle(event: ToggleEvent<HTMLDivElement>) {
    if (event.newState !== "open") return;
    attempt.current = crypto.randomUUID();
    if (status === "sent") setStatus("idle");
  }

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    if (data.get("may_reply") === "true") data.set("email", email);
    data.set("route", window.location.pathname);
    data.set("app_version", appVersion);
    data.set("account_id", accountId);
    data.set("viewport", `${window.innerWidth}x${window.innerHeight}`);
    setStatus("sending");
    setError("");
    const result = await submit(FORM_ID, data, { idempotencyKey: attempt.current });
    if (result.ok) {
      setStatus("sent");
      return;
    }
    setError(result.error.fields?.rating?.[0] ?? result.error.message);
    setStatus("idle");
  }

  return (
    <>
      <button type="button" className="feedback-trigger" popoverTarget={panelId}>
        Feedback
      </button>
      <div id={panelId} popover="auto" className="feedback-panel" onToggle={onToggle} aria-label="Send feedback">
        {status === "sent" ? (
          <p role="status">Thanks, that helps.</p>
        ) : (
          <form onSubmit={onSubmit}>
            <fieldset>
              <legend>How is it going?</legend>
              {FACES.map((face, index) => (
                <label key={face}>
                  <input type="radio" name="rating" value={index + 1} required /> {face}
                </label>
              ))}
            </fieldset>
            <label htmlFor={`${panelId}-message`}>Tell us more</label>
            <textarea id={`${panelId}-message`} name="message" rows={4} maxLength={4000} />
            <label>
              <input type="checkbox" name="may_reply" value="true" /> The team can reply to me by email
            </label>
            <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
            {error ? <p role="alert">{error}</p> : null}
            <button disabled={status === "sending"}>{status === "sending" ? "Sending" : "Send"}</button>
          </form>
        )}
      </div>
    </>
  );
}
```

- `popover="auto"` menaruh panel di top layer saat terbuka. Panel tampil di atas dialog dan menu Anda tanpa `z-index`, dan tertutup saat pengguna mengklik di tempat lain.
- Child panel tetap ter-mount saat panel tersembunyi, sehingga draf selamat dari klik tak sengaja di luar. Setelah pengiriman berhasil, baris terima kasih menggantikan formulir, yang mengosongkannya, dan pembukaan berikutnya menampilkan formulir kosong.
- `onToggle` membuat key baru setiap kali panel dibuka. `submit()` mengirimnya sebagai header `Idempotency-Key`, jadi klik ganda hanya menyimpan satu jawaban. Lihat [idempotensi](https://ikiform.com/id/docs/frameworks.md#idempotency).
- Email hanya diisi jika kotaknya dicentang. Jika tidak dicentang, jawaban membawa ID akun tetapi tanpa alamat.

Gaya default popover menaruhnya di tengah layar. Aturan ini menyematkannya di atas tombol:

`components/feedback-widget.css`:

```text
.feedback-trigger {
  position: fixed;
  right: 16px;
  bottom: 16px;
}

.feedback-panel {
  inset: auto 16px 64px auto;
  margin: 0;
  width: min(360px, calc(100vw - 32px));
  max-height: calc(100dvh - 96px);
  overflow: auto;
}
```

## Mount sekali saja

Render widget di layout yang membungkus layar untuk pengguna yang login, agar muncul di mana-mana dan tetap ada saat navigasi. Di App Router Next.js, layout adalah server component yang membaca sesi dan meneruskan props biasa ke widget klien:

`app/(app)/layout.tsx`:

```tsx
import type { ReactNode } from "react";
import { FeedbackWidget } from "@/components/feedback-widget";
import { getSession } from "@/lib/session";

export default async function AppLayout({ children }: { children: ReactNode }) {
  const session = await getSession();
  return (
    <>
      {children}
      {session ? (
        <FeedbackWidget
          accountId={session.accountId}
          email={session.email}
          appVersion={process.env.NEXT_PUBLIC_APP_VERSION ?? "dev"}
        />
      ) : null}
    </>
  );
}
```

Tandai file widget dengan `"use client"` di App Router. Di aplikasi Vite atau React Router, render di samping router outlet Anda. Cara kerjanya sama.

## Apa yang bisa dan tidak bisa dibuktikan browser

Widget ini mengirim langsung dari browser dengan ID publik formulir. Itu kompromi yang tepat untuk feedback: tidak ada server route yang perlu dirawat, dan `meta.user_agent` milik Ikiform adalah browser asli pengguna. Itu juga berarti siapa pun bisa mengirim `account_id` apa pun. Gunakan untuk mencari informasi, jangan pernah untuk memberikan akses apa pun.

> Jika akun harus bisa dipercaya, misalnya untuk menampilkan feedback di dalam alat admin, kirim dari server Anda dengan secret key dan tambahkan akun dari sesi di sana. [Versi server](https://ikiform.com/id/guides/feedback-form.md#form) di pilar menunjukkan polanya; pilih rate limit Relaxed atau Custom, karena setiap kiriman kemudian berasal dari IP server Anda.

## Pastikan berfungsi

1. Di **Allowed domains** formulir, tambahkan domain situs Anda, ditambah `localhost` selama pengembangan. Biarkan kosong untuk menerima kiriman dari mana saja.
2. Buka panel, ketik catatan, klik di luar, lalu buka lagi. Catatannya masih ada.
3. Buka modal di aplikasi Anda, lalu widget. Panel tampil di atas modal.
4. Tekan Escape. Panel tertutup dan fokus kembali ke tombol Feedback.
5. Pilih wajah dan klik dua kali Kirim. **Submissions** menampilkan satu jawaban, dengan `route`, `app_version`, dan `account_id` terisi.
6. Kirim sekali dengan kotak balasan dicentang. Tombol Balas di email notifikasi menuju ke pengguna.

## Hal yang sering menjebak

- Jauhkan tombol dari elemen mengambang Anda sendiri, seperti gelembung chat atau banner cookie. Pindahkan salah satunya, atau buka widget dari item menu sebagai gantinya.
- Jangan tampilkan tombol di setiap layar checkout atau editor tempat orang sedang berkonsentrasi. Sembunyikan di sana dengan prop.
- Popover API didukung di Chrome, Edge, Firefox, dan Safari versi terbaru. Jika Anda harus mendukung browser lama, periksa dulu sebelum mengandalkannya.
- Setiap field yang ditambahkan lewat kode harus ada di definisi formulir, atau Ikiform menolak kiriman dengan error Unknown field.
- Jangan kirim seluruh state aplikasi. Route, versi, akun, dan ukuran menjawab sebagian besar pertanyaan; selebihnya adalah soal privasi.

## Pemecahan masalah

| Masalah | Yang harus dilakukan |
| --- | --- |
| `403 origin_not_allowed`, atau setiap kiriman masuk ke spam | Domain halaman tidak ada di **Allowed domains** formulir. Tambahkan, ditambah `localhost` untuk uji lokal. Subdomain dari domain yang terdaftar diizinkan. |
| `403 challenge_required` | Spam check formulir diatur ke **Ikiform** dan kiriman `fetch` tidak membawa token. Ambil token dengan `challenge(formId)` dari `@ikiform/sdk` sebelum setiap pengiriman, atau matikan pemeriksaannya. |
| Panel muncul di tengah layar | Stylesheet tidak termuat, jadi gaya popover default browser yang berlaku. Periksa import CSS, atau atur `inset` dan `margin` pada panel. |
| `422 validation_failed` | Kiriman tidak cocok dengan definisi field formulir. `error.fields` menyebutkan setiap field dan alasannya. Samakan `name` input dengan ID field. |

## Pertanyaan

### Bagaimana cara menambahkan tombol feedback ke aplikasi React?

Render tombol fixed dengan popoverTarget yang mengarah ke panel dengan popover=auto. Browser menangani pembukaan, penutupan, dan fokus. Kirim formulir di panel dengan submit() dari @ikiform/sdk dan tambahkan route serta versi aplikasi saat submit.

### Mengapa memakai Popover API alih-alih library modal?

Popover dirender di top layer, jadi berada di atas dialog Anda sendiri tanpa aturan z-index, dan light dismiss serta Escape sudah tersedia otomatis. Panel feedback tidak perlu memblokir halaman seperti modal.

### Apakah ID akun di widget bisa dipercaya?

Tidak. Apa pun yang dikirim dari browser bisa diubah oleh pengguna. Gunakan untuk mencari konteks, dan kirim dari server Anda dengan secret key saat identitas harus bisa dipercaya.

### Apakah widget butuh server?

Tidak. Widget mengirim langsung ke Ikiform dari browser, dan Allowed domains membatasi situs mana yang bisa memakai formulir. Tambahkan server route hanya jika Anda butuh identitas yang tepercaya.

## 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 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.
- [Survei NPS](https://ikiform.com/id/guides/feedback-form/nps-survey.md): Jalankan survei NPS dengan skor 0 sampai 10 di email, pertanyaan lanjutan di halaman Anda sendiri yang berubah sesuai skor, dan script yang menghitung NPS.
- [Feedback beta tester](https://ikiform.com/id/guides/feedback-form/beta-tester-feedback.md): Formulir feedback beta dengan tautan pribadi per tester dan build, dua skala rating, dan ringkasan mingguan yang menunjukkan apakah setiap build makin baik.
- [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.
- [Survei pembatalan](https://ikiform.com/id/guides/feedback-form/cancellation-survey.md): Tanyakan mengapa pelanggan membatalkan di dalam alur pembatalan SaaS Anda, jaga Skip tetap satu klik, simpan alasannya ke Stripe dan Ikiform, dan jangan pernah menunda pembatalan.
