Formulaire de feedback pour un site ou une application8 min de lecture
Créer un formulaire de feedback dans l'application avec React Router
Un formulaire de feedback React Router 8 : l'action d'une resource route lit la session cookie, un fetcher envoie depuis le layout et un envoi sans JS redirige en retour.
Montez un seul formulaire de feedback dans le layout qui entoure vos routes connectées et envoyez-le avec useFetcher vers une resource route. Son action lit l'utilisateur dans votre session cookie, prend la page dans Referer et transmet la note à votre backend de formulaires avec une clé secrète. Quand JavaScript n'est pas encore chargé, la même action redirige vers la page au lieu de répondre avec du JSON brut.
L'onglet React Router du guide principal envoie un formulaire de page public via un fetcher. Ici, le formulaire vit dans votre application, derrière une connexion, et c'est l'action qui décide qui l'a envoyé. Pour les clés, les domaines autorisés et les bases, voyez le guide des frameworks serveur. Le code cible React Router 8 en mode framework.
Ce que change un fetcher dans le layout
Un formulaire de feedback dans un layout est sur tous les écrans à la fois, et React Router le garde monté pendant la navigation. C'est ce que vous voulez, et cela soulève quatre questions que le guide principal n'a jamais à traiter :
| Point d'attention | Dans un layout | Comment cette page le gère |
|---|---|---|
| Qui l'a envoyé | Le navigateur pourrait se réclamer de n'importe quel utilisateur | L'action lit la session cookie signée |
| Quel écran | Le formulaire ne sait pas quelle route enfant est affichée | Chaque route se nomme dans handle, lu avec useMatches |
| État résiduel | Les data d'un fetcher survivent à la navigation | Un fetcher par écran grâce à l'option key |
| Pas encore de JavaScript | fetcher.Form est un vrai formulaire, donc le navigateur l'envoie à la resource route | L'action détecte un envoi de document et redirige en retour |
- L'utilisateur note l'écran dans le formulaire que le layout affiche sur chaque route connectée.
- Après l'hydratation, le fetcher envoie en arrière-plan. L'action lit la session cookie, la page dans Referer et le nom de l'écran.
- L'action envoie à Ikiform avec la clé secrète et l'utilisateur joint.
- Le résultat revient via data() et s'affiche sur place, sans navigation.
- Avant l'hydratation, le navigateur envoie le formulaire comme un document. L'action voit une navigation et redirige vers la page d'origine avec le résultat dans l'URL.
Mise en place du projet
Les fichiers concernés, dans un projet créé avec create-react-router :
app/
routes.ts
sessions.server.ts
components/
feedback-form.tsx
routes/
api.feedback.ts
app-layout.tsx
project-settings.tsxAjoutez une resource route pour l'action et un layout sans chemin pour les écrans connectés. Un module avec une action et sans export par défaut est une resource route : il n'affiche rien.
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;Créez une clé secrète sous API keys avec le scope submissions:write. Le serveur lit les deux valeurs depuis process.env ; définissez-les là où tourne votre serveur, et ne donnez jamais à la clé un préfixe VITE_, qui la placerait dans le bundle client.
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
SESSION_SECRET=a-long-random-stringCollez la définition ci-dessous dans l'onglet Fields du formulaire. La personne remplit deux champs ; l'action fournit le reste, et l'email issu de la session fait que Reply sur chaque notification lui écrit.
Aperçu
Exécute le validateur d'Ikiform dans votre navigateur. Rien n'est envoyé.
rating de 9 ou un champ absent de la définition, et vous verrez le 422 que reçoit votre action.La construire
Lire l'utilisateur dans la session cookie
createCookieSessionStorage signe le cookie avec secrets : un utilisateur peut le lire mais pas le modifier. Si votre connexion écrit déjà une session, réutilisez son stockage et ajoutez 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;
}Le suffixe .server garde le module hors du bundle client ; l'importer depuis un composant fait échouer le build.
La 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);
}samePagene garde le chemin que siRefererest votre propre origine, et supprime les paramètres d'URL, où les URL d'application portent des id et des tokens.Sec-Fetch-Modevautnavigatequand le navigateur soumet lui-même le formulaire comme un chargement de page. Les fetchers l'envoient comme une requête d'arrière-plan : ils reçoivent doncdata(), et tous les autres une redirection.- Les erreurs utilisent un
data()4xx ou 5xx plutôt qu'une exception. Une exception remonterait jusqu'à l'ErrorBoundaryle plus proche et remplacerait l'écran sur lequel l'utilisateur travaillait. Originreçoit l'origine propre de la requête. C'esthttp://localhost:5173avecreact-router dev, et votre domaine en production.
Le formulaire
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>
);
}useScreenparcourt les routes correspondantes et garde lehandle.screenle plus profond, pour qu'une route imbriquée puisse se nommer plus précisément que son parent.- La
keydu fetcher inclut l'écran. Après un envoi sur un écran, l'écran suivant affiche un formulaire vierge au lieu du remerciement précédent. fetcher.statevautsubmittingpuisloadingpendant que les loaders correspondants se revalident. Les deux comptent comme occupés, donc le bouton reste désactivé jusqu'à l'arrivée de la réponse.- Sans JavaScript, la redirection ramène sur la page avec
?feedback=sent, etuseSearchParamsaffiche la même ligne de remerciement.
Le monter une seule fois
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>;
}L'écran de réglages de chaque projet remonte comme projects/settings, pour que leurs moyennes se cumulent ; le chemin concret avec le vrai id reste dans page.
Honeypot, vérification humaine et limites
L'input _gotcha traverse l'action sans être modifié, et un honeypot rempli est stocké comme spam. La vérification humaine d'Ikiform est ignorée pour les envois avec une clé secrète, et la vérification de session prend sa place : un visiteur sans cookie signé ne fait rien envoyer. Il reste à surveiller un compte qui envoie en boucle, et Ikiform compte tous ces envois sur l'IP de votre serveur.
Les versions côté serveur envoient avec une clé secrète, donc Ikiform ignore Allowed domains et sa vérification humaine. Chaque envoi vient alors de l'IP de votre serveur, et la limite Standard de 5 par minute et par visiteur compte tous vos visiteurs ensemble. Choisissez la limite de débit Relaxed ou Custom pour un formulaire que vous envoyez via un serveur, et gardez IKIFORM_SECRET_KEY dans des variables d'environnement réservées au serveur.
SSR, mode SPA et prérendu
L'action a besoin d'un serveur. Avec ssr: false dans react-router.config.ts, le build n'a pas de serveur et les actions serveur ne peuvent pas s'exécuter ; vous reviendriez à une clientAction qui envoie depuis le navigateur sans identité fiable. Prérendre les pages marketing ne pose pas de problème, car l'action est une route distincte qui s'exécute toujours à la requête. Gardez ssr: true pour la partie connectée de l'application.
Déployer
| Hébergeur | Que faire |
|---|---|
| Vercel | Installez @vercel/react-router et ajoutez vercelPreset() aux presets dans react-router.config.ts. Ajoutez les deux variables dans les réglages du projet. |
| Netlify | Ajoutez @netlify/vite-plugin-react-router aux plugins Vite. Les actions s'exécutent comme des Netlify Functions ; définissez les deux variables dans les réglages d'environnement du site. |
| Cloudflare | Utilisez le modèle React Router de Cloudflare avec @cloudflare/vite-plugin. Lisez les bindings avec import { env } from "cloudflare:workers" ; dans React Router 8, le context de l'action ne porte plus cloudflare.env. Lisez-y la clé, et stockez-la comme secret du Worker, avec .dev.vars pour les exécutions locales. |
Tests gratuits depuis localhost
Ikiform donne à un espace de travail sans offre 50 soumissions de test gratuites par mois depuis localhost, d'après l'en-tête Origin. Un fetch depuis votre action n'a pas d'Origin sauf si vous en ajoutez un, et un envoi sans origine est traité comme un envoi serveur en production : 402 plan_required, retenu jusqu'à ce que vous choisissiez une offre. Transmettre l'origine de la requête fait compter react-router dev comme un test local.
Vérifiez que tout fonctionne
- Lancez
react-router dev, connectez-vous, ouvrez les réglages d'un projet, choisissez Painful et ajoutez un commentaire. - Vérifiez Submissions :
screenvautprojects/settings,pagecontient le vrai id sans paramètres d'URL, etuser_idetemailcorrespondent à votre compte. - Passez à un autre écran. Le formulaire y est vide, sans le remerciement précédent.
- Désactivez JavaScript et envoyez à nouveau. La page se recharge avec
?feedback=sentet la ligne de remerciement. - Effacez le cookie de session et envoyez. Le formulaire affiche le message de connexion.
Pièges
| Problème | Que faire |
|---|---|
| Le navigateur affiche du JSON brut après l'envoi | L'envoi est arrivé comme une navigation et l'action a renvoyé data(). Vérifiez la branche Sec-Fetch-Mode, et que rien devant votre serveur ne supprime cet en-tête. |
402 plan_required pendant les tests en local | L'envoi n'avait pas d'origine locale. Gardez l'en-tête Origin dans l'action et ouvrez l'application sur localhost. |
screen vaut toujours unknown | Aucune route correspondante n'exporte de handle avec screen, ou la valeur contient des majuscules ou des espaces. Utilisez des noms en minuscules, en forme de chemin. |
| L'error boundary remplace la page quand un envoi échoue | Quelque chose dans l'action a levé une exception. Renvoyez data() avec un statut pour chaque échec prévu. |
readUser renvoie null juste après la connexion | SESSION_SECRET diffère entre le code qui a écrit le cookie et celui-ci, donc la signature échoue. Utilisez un seul module de stockage pour les deux. |
Questions
Oui. Donnez à fetcher.Form une action qui pointe vers la resource route. Renvoyez data() depuis l'action pour que le fetcher reçoive le résultat, et redirigez quand la requête est une navigation du navigateur, pour qu'un envoi avant l'hydratation n'affiche pas de JSON brut.
Lisez la session depuis l'en-tête Cookie avec la fonction getSession de votre stockage de session, puis lisez-y l'id de l'utilisateur. Ne faites jamais confiance à un id utilisateur envoyé par le formulaire.
Non. Avec ssr à false, il n'y a pas de serveur pour exécuter l'action. Vous pourriez envoyer depuis une clientAction, mais c'est alors le navigateur qui fournit l'identité, et elle n'est pas fiable.
Non, il n'y a pas d'offre gratuite. Les tests sont gratuits : vous pouvez envoyer 50 soumissions de test par mois depuis localhost avant de payer. Pour passer en production, Pro commence à 6 $/mois pour 500 soumissions, ou vous pouvez payer une seule fois 10 000 soumissions pour 49 $ ou 50 000 soumissions pour 149 $, avec des crédits qui n'expirent jamais.
Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.