Formulaire de feedback pour un site ou une application

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.

Preet SutharFondateur, Ikiform

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'attentionDans un layoutComment cette page le gère
Qui l'a envoyéLe navigateur pourrait se réclamer de n'importe quel utilisateurL'action lit la session cookie signée
Quel écranLe formulaire ne sait pas quelle route enfant est affichéeChaque route se nomme dans handle, lu avec useMatches
État résiduelLes data d'un fetcher survivent à la navigationUn fetcher par écran grâce à l'option key
Pas encore de JavaScriptfetcher.Form est un vrai formulaire, donc le navigateur l'envoie à la resource routeL'action détecte un envoi de document et redirige en retour
Deux façons pour un envoi de feedback React Router d'atteindre la même resource routefetcher.Formdans lelayout del'applicationactionapi/feedbacksession,Referer,écranIkiformclésecrète,stockefetcher.datamerci ou lemessageredirectionenretour?feedback=sentsur la mêmepagefetch+ utilisateur, pagedata()Sec-Fetch-Mode: navigateDeux façons pour un envoi de feedback React Router d'atteindre la même resource routefetcher.Formdans le layout del'applicationactionapi/feedbacksession, Referer,écranIkiformclé secrète, stockefetcher.datamerci ou le messageredirectionen retour?feedback=sentsur la mêmepagefetch+ utilisateur, pagedata()Sec-Fetch-Mode: navigate
  1. L'utilisateur note l'écran dans le formulaire que le layout affiche sur chaque route connectée.
  2. 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.
  3. L'action envoie à Ikiform avec la clé secrète et l'utilisateur joint.
  4. Le résultat revient via data() et s'affiche sur place, sans navigation.
  5. 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.
Une seule action, deux types d'appelants. Seule une navigation du navigateur reçoit une redirection ; les appels du fetcher reçoivent des données.

Mise en place du projet

Les fichiers concernés, dans un projet créé avec create-react-router :

Arborescence du projet
app/
  routes.ts
  sessions.server.ts
  components/
    feedback-form.tsx
  routes/
    api.feedback.ts
    app-layout.tsx
    project-settings.tsx

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

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;

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.

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

Collez 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é.

Comment cet écran fonctionne-t-il pour vous ?

Renseigné par votre serveur à partir de la requête.

Envoyez un 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.

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

Le suffixe .server garde le module hors du bundle client ; l'importer depuis un composant fait échouer le build.

La 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 ne garde le chemin que si Referer est votre propre origine, et supprime les paramètres d'URL, où les URL d'application portent des id et des tokens.
  • Sec-Fetch-Mode vaut navigate quand 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 donc data(), et tous les autres une redirection.
  • Les erreurs utilisent un data() 4xx ou 5xx plutôt qu'une exception. Une exception remonterait jusqu'à l'ErrorBoundary le plus proche et remplacerait l'écran sur lequel l'utilisateur travaillait.
  • Origin reçoit l'origine propre de la requête. C'est http://localhost:5173 avec react-router dev, et votre domaine en production.

Le formulaire

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 parcourt les routes correspondantes et garde le handle.screen le plus profond, pour qu'une route imbriquée puisse se nommer plus précisément que son parent.
  • La key du 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.state vaut submitting puis loading pendant 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, et useSearchParams affiche la même ligne de remerciement.

Le monter une seule fois

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

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ébergeurQue faire
VercelInstallez @vercel/react-router et ajoutez vercelPreset() aux presets dans react-router.config.ts. Ajoutez les deux variables dans les réglages du projet.
NetlifyAjoutez @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.
CloudflareUtilisez 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

  1. Lancez react-router dev, connectez-vous, ouvrez les réglages d'un projet, choisissez Painful et ajoutez un commentaire.
  2. Vérifiez Submissions : screen vaut projects/settings, page contient le vrai id sans paramètres d'URL, et user_id et email correspondent à votre compte.
  3. Passez à un autre écran. Le formulaire y est vide, sans le remerciement précédent.
  4. Désactivez JavaScript et envoyez à nouveau. La page se recharge avec ?feedback=sent et la ligne de remerciement.
  5. Effacez le cookie de session et envoyez. Le formulaire affiche le message de connexion.

Pièges

ProblèmeQue faire
Le navigateur affiche du JSON brut après l'envoiL'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 localL'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 unknownAucune 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 échoueQuelque 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 connexionSESSION_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

Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.