Formulário de feedback para um site ou app

Formulário de feedback para um site ou app8 min de leitura

Crie um formulário de feedback dentro do app com React Router

Um formulário de feedback com React Router 8: a action de uma resource route lê a sessão do cookie, um fetcher envia do layout e um envio sem JS redireciona de volta.

Preet SutharFundador, Ikiform

Monte um formulário de feedback no layout em volta das suas rotas logadas e envie-o com useFetcher para uma resource route. A action dela lê o usuário da sessão em cookie, pega a página do Referer e encaminha a avaliação ao seu backend de formulários com uma chave secreta. Quando o JavaScript ainda não carregou, a mesma action redireciona de volta para a página em vez de responder com JSON cru.

A aba React Router do guia principal envia um formulário de página pública por um fetcher. Aqui o formulário fica dentro do seu app, atrás do login, e a action decide quem o enviou. Para chaves, domínios permitidos e o básico, veja o guia de frameworks de servidor na documentação. O código é para o React Router 8 em modo framework.

O que um fetcher no layout muda

Um formulário de feedback num layout fica em todas as telas ao mesmo tempo, e o React Router o mantém montado enquanto o usuário navega. É isso que você quer, e isso levanta quatro questões que o guia principal nunca precisa responder:

QuestãoNum layoutComo esta página resolve
Quem enviouO navegador poderia dizer que é qualquer usuárioA action lê a sessão assinada do cookie
Qual telaO formulário não sabe qual rota filha está aparecendoCada rota dá nome a si mesma em handle, lido com useMatches
Estado que sobraO data de um fetcher sobrevive à navegaçãoUm fetcher por tela pela opção key
Ainda sem JavaScriptfetcher.Form é um formulário de verdade, então o navegador o envia para a resource routeA action detecta um envio de documento e redireciona de volta
Dois caminhos pelos quais um envio de feedback do React Router chega à mesma resource routefetcher.Formno layoutdo appactionapi/feedbacksessão,Referer,telaIkiformchavesecreta,armazenafetcher.dataobrigadoou amensagemredirecionade volta?feedback=sentna mesmapáginafetch+ usuário, páginadata()Sec-Fetch-Mode: navigateDois caminhos pelos quais um envio de feedback do React Router chega à mesma resource routefetcher.Formno layout do appactionapi/feedbacksessão, Referer, telaIkiformchave secreta,armazenafetcher.dataobrigado ou a mensagemredirecionade volta?feedback=sentna mesmapáginafetch+ usuário, páginadata()Sec-Fetch-Mode: navigate
  1. O usuário avalia a tela no formulário que o layout renderiza em toda rota logada.
  2. Depois da hidratação, o fetcher envia em segundo plano. A action lê a sessão do cookie, a página do Referer e o nome da tela.
  3. A action envia ao Ikiform com a chave secreta e o usuário anexado.
  4. O resultado volta por data() e aparece no lugar, sem navegação.
  5. Antes da hidratação, o navegador envia o formulário como documento. A action vê uma navegação e redireciona para a página de origem com o resultado na query.
Uma action, dois tipos de chamador. Só uma navegação do navegador recebe redirect; chamadas de fetcher recebem dados.

Configuração do projeto

Os arquivos envolvidos, num projeto criado com create-react-router:

Árvore do projeto
app/
  routes.ts
  sessions.server.ts
  components/
    feedback-form.tsx
  routes/
    api.feedback.ts
    app-layout.tsx
    project-settings.tsx

Adicione uma resource route para a action e um layout sem caminho para as telas logadas. Um módulo com action e sem export default é uma resource route, então não renderiza nada.

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;

Crie uma chave secreta em API keys com o escopo submissions:write. O servidor lê os dois valores de process.env; defina-os onde o seu servidor roda e nunca dê à chave o prefixo VITE_, que a colocaria no bundle do cliente.

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

Cole a definição abaixo na aba Fields do formulário. A pessoa digita dois campos; a action fornece o resto, e o email da sessão faz o Reply de cada notificação chegar até ela.

Prévia

Roda o validador do Ikiform no seu navegador. Nada é enviado.

Como esta tela está funcionando para você?

Definido pelo seu servidor a partir da requisição.

Envie um rating igual a 9 ou um campo que a definição não lista, e você verá o 422 que a sua action recebe.

Construa

Leia o usuário da sessão em cookie

createCookieSessionStorage assina o cookie com secrets, então um usuário consegue lê-lo, mas não alterá-lo. Se o seu login já grava uma sessão, reutilize o storage dela e adicione 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;
}

O sufixo .server mantém o módulo fora do bundle do cliente; importá-lo de um componente quebra o build.

A 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 mantém o caminho só quando o Referer é da sua própria origem, e descarta a query string, onde URLs de app carregam ids e tokens.
  • Sec-Fetch-Mode é navigate quando o próprio navegador envia o formulário como carregamento de página. Fetchers o enviam como requisição em segundo plano, então recebem data(), e todo o resto recebe um redirect.
  • Os erros usam um data() 4xx ou 5xx em vez de um throw. Um erro lançado subiria até o ErrorBoundary mais próximo e substituiria a tela em que o usuário estava trabalhando.
  • Origin é definido como a própria origem da requisição. Isso é http://localhost:5173 no react-router dev, e o seu domínio em produção.

O formulário

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 percorre as rotas correspondentes e fica com o handle.screen mais profundo, então uma rota aninhada pode se nomear com mais precisão que a rota pai.
  • A key do fetcher inclui a tela. Depois de um envio numa tela, a próxima tela mostra um formulário novo em vez do agradecimento anterior.
  • fetcher.state é submitting e depois loading enquanto os loaders correspondentes revalidam. Os dois contam como ocupado, então o botão fica desativado até a resposta chegar.
  • Sem JavaScript, o redirect volta para a página com ?feedback=sent, e useSearchParams mostra a mesma frase de agradecimento.

Monte uma vez

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

A tela de configurações de todos os projetos aparece como projects/settings, então as médias são calculadas juntas; o caminho concreto com o id real continua em page.

Honeypot, verificação humana e limites

O input _gotcha passa intacto pela action, e um preenchido é armazenado como spam. A verificação humana do Ikiform é pulada em envios com chave secreta, e a verificação de sessão toma o lugar dela: um visitante sem cookie assinado não envia nada. O que sobra para vigiar é uma conta enviando em loop, e o Ikiform conta todos esses envios contra o IP do seu servidor.

As versões no servidor enviam com uma chave secreta, então o Ikiform pula Allowed domains e a verificação humana. Todo envio passa a vir do IP do seu servidor, e o limite Standard de 5 por minuto por visitante conta todos os seus visitantes juntos. Escolha o limite de taxa Relaxed ou Custom para um formulário que você envia por um servidor, e mantenha IKIFORM_SECRET_KEY em variáveis de ambiente só do servidor.

SSR, modo SPA e pré-renderização

A action precisa de um servidor. Com ssr: false em react-router.config.ts, o build não tem servidor, e server actions não rodam; você voltaria a uma clientAction enviando do navegador sem identidade confiável. Pré-renderizar páginas de marketing não tem problema, porque a action é uma rota separada que continua rodando por requisição. Mantenha ssr: true na parte logada do app.

Deploy

HostO que fazer
VercelInstale @vercel/react-router e adicione vercelPreset() a presets em react-router.config.ts. Adicione as duas variáveis nas configurações do projeto.
NetlifyAdicione @netlify/vite-plugin-react-router aos plugins do Vite. As actions rodam como Netlify Functions; defina as duas variáveis nas configurações de ambiente do site.
CloudflareUse o template de React Router da Cloudflare com @cloudflare/vite-plugin. Leia bindings com import { env } from "cloudflare:workers"; no React Router 8, o context da action não traz mais cloudflare.env. Leia a chave ali e guarde-a como secret do Worker, com .dev.vars para rodar localmente.

Testes gratuitos a partir do localhost

O Ikiform dá a um workspace sem plano 50 envios de teste gratuitos por mês a partir de localhost, julgados pelo header Origin. Um fetch da sua action não tem Origin a menos que você adicione um, e um envio sem origem é tratado como envio real de servidor: 402 plan_required, retido até você escolher um plano. Repassar a origem da requisição faz o react-router dev contar como teste local.

Confira se funciona

  1. Rode react-router dev, faça login, abra as configurações de um projeto, escolha Painful e adicione um comentário.
  2. Confira Submissions: screen é projects/settings, page traz o id real sem query string, e user_id e email batem com a sua conta.
  3. Vá para outra tela. O formulário ali está vazio, sem mostrar o agradecimento anterior.
  4. Desligue o JavaScript e envie de novo. A página recarrega com ?feedback=sent e a frase de agradecimento.
  5. Apague o cookie de sessão e envie. O formulário mostra a mensagem de login.

Pegadinhas

ProblemaO que fazer
O navegador mostra JSON cru depois do envioO envio chegou como navegação e a action devolveu data(). Confira o ramo de Sec-Fetch-Mode e se nada na frente do seu servidor remove esse header.
402 plan_required ao testar localmenteO envio não tinha origem local. Mantenha o header Origin na action e abra o app em localhost.
screen é sempre unknownNenhuma rota correspondente exporta um handle com screen, ou o valor tem maiúsculas ou espaços. Use nomes em minúsculas, parecidos com caminhos.
O error boundary substitui a página quando um envio falhaAlgo na action lançou um erro. Devolva data() com um status para cada falha esperada.
readUser devolve null logo depois do loginSESSION_SECRET é diferente entre o código que gravou o cookie e este, então a assinatura falha. Use um único módulo de storage para os dois.

Perguntas

Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.