Inicio / Artículos

Un formulario de invitación único para Server Actions, createServerFn y tRPC

Un formulario de invitación creado de tres maneras: Next.js Server Actions, TanStack createServerFn y tRPC, comparados en cuanto a tipos, bundles, comportamiento sin JavaScript y errores de Zod.

Editor: REACTAPP.TOP7 min de lectura1626 palabras

Fuentes: One Form, Three Backends: Server Action, createServerFn, tRPC.

Tres backends, un campo de correo electrónico

Conecta el mismo campo de invitación de tres maneras diferentes y compara lo que realmente importa: los tipos transmitidos, cuánto paga el paquete y qué ocurre cuando el manejador lanza un error o cuando JavaScript está desactivado.

La interfaz de usuario es muy sencilla: un campo de entrada email. Se envía el formulario; en caso de éxito aparece una línea debajo del botón, y en caso de fallo también hay una línea debajo. Eso es todo lo que ofrece el producto.

Los manejadores son una Next Server Action, un TanStack createServerFn y una mutación tRPC. Se comparte el esquema Zod y la copia de datos. Es la misma aplicación con cuatro rutas, además de un árbol de inicio para que createServerFn no funcione como algo extraño. Los manifiestos que indican qué tecnología será “la predeterminada en 2026” no son útiles para el formulario; lo que realmente importa es si los errores llegan como cadenas procesables, si los tipos se mantienen intactos durante la transmisión y si la página sigue funcionando sin JavaScript del cliente.

Lo que realmente promete cada tecnología

En Next, marque una función con "use server" y dirija la propiedad action del formulario hacia ella; opcionalmente, mediante useActionState para los estados pendientes y de resultado.

En TanStack Start, createServerFn proporciona una función llamable tipada con validación. Para llamarla desde un envío de formulario nativo se necesita su propio adaptador, ya que por defecto no llega como acción de formulario progresivo.

En tRPC, define un procedimiento de mutación e invoca desde el cliente. Funcionar sin JavaScript no forma parte de ese diseño.

Esos contratos publicados delimitaron el experimento.

El esquema reutilizado en todas partes

export const inviteSchema = z.object({
  email: z.string().email(),
});
export type InviteInput = z.infer<typeof inviteSchema>;

Un solo esquema, tres manejadores. Si una plataforma obliga a usar un segundo dialecto de las mismas reglas, ese costo se refleja en la evaluación.

Ruta de acción del servidor

"use server";

import { inviteSchema } from "./schema";

export async function invite(prev: { error?: string } | null, formData: FormData) {
  const parsed = inviteSchema.safeParse({
    email: formData.get("email"),
  });
  if (!parsed.success) return { error: parsed.error.issues[0].message };
  await db.invites.create(parsed.data.email);
  return { error: undefined, ok: true };
}
"use client";
import { useActionState } from "react";
import { invite } from "./actions";

export function InviteForm() {
  const [state, action, pending] = useActionState(invite, null);
  return (
    <form action={action}>
      <input name="email" type="email" required />
      <button disabled={pending}>Invite</button>
      {state?.error ? <p>{state.error}</p> : null}
    </form>
  );
}

Aunque JavaScript esté deshabilitado, el formulario sigue enviándose. Ese es el contrato de Action, y se mantiene en este laboratorio.

Ruta createServerFn

export const inviteFn = createServerFn({ method: "POST" })
  .inputValidator(inviteSchema)
  .handler(async ({ data }) => {
    await db.invites.create(data.email);
    return { ok: true as const };
  });

Al iniciar, el formulario llama a inviteFn({ data: { email } }) desde onSubmit. Sin adaptadores adicionales, esta ruta necesita JavaScript. Inventar un envoltorio falso de mejora progresiva y fingir que viene incluido con el framework engañaría la comparación.

Ruta tRPC

invite: publicProcedure.input(inviteSchema).mutation(async ({ input }) => {
  await db.invites.create(input.email);
  return { ok: true as const };
}),

El uso en el cliente era api.invite.useMutation(). Los tipos de input.email eran excelentes. Sin JavaScript, el botón no sirve para nada.

Cómo obtuvieron puntaje los tres

Tipos. tRPC y createServerFn infirieron los datos de entrada a partir de Zod sin necesidad de un segundo tipo de cliente. La acción del servidor aceptó FormData y se basó en safeParse. Es seguro, pero no es lo mismo que una llamada de cliente con tipos definidos.

Bulto. La estructura de la acción apenas cambió. tRPC incluyó los costos asociados al cliente/proveedor que ya se pagan en la interfaz de API. createServerFn utilizó el cliente funcional de Start. El tRPC existente hace que las mutaciones sean económicas; en cambio, implementar tRPC desde cero para un solo correo electrónico no lo es.

Lanzamientos de errores. Al forzar que db.invites.create falle:

  • Acción: los valores devueltos se muestran en useActionState debajo del botón. Un lanzamiento de error no capturado llega al límite de errores de Next hasta que se gestione.
  • createServerFn: la promesa es rechazada; se gestiona esto en onSubmit.
  • tRPC: isError en la mutación; mismo proceso de mapeo.
  • JS desactivado. La acción funcionó. Las otras dos no. Eso es una diferencia en el contrato, no un ranking moral. Las páginas de configuración de marketing que deben funcionar sin JS deberían usar acciones. Los paneles de control que ya requieren JS pueden ignorar ese eje.

    El costo más allá del esquema

    Los adaptadores ocuparon toda la tarde, no Zod.

    La forma del error cambió hasta estandarizarse en { error?: string }. tRPC prefería error.message; úsalo una sola vez. No dejes que tres formas lleguen al párrafo debajo del botón.

    La autenticación fue diferente: las acciones utilizaban cookies de solicitud de documento; tRPC usaba el contexto de la procedura; createServerFn utilizaba la solicitud de Start. Todas pueden ser correctas; solo una representa “el formulario enviado”.

    La actualización del caché tras el éxito también se triplicó: Acción updateTag("invites"), tRPC utils.invites.list.invalidate(); se inicia la invalidación del cargador. La misma oración, tres variantes.

    Cuándo usar cada método

    Preferir una Acción de Servidor cuando el control sea un formulario real en Next y siga siendo importante no usar JS. Envuélvalo con useActionState, devuelva errores y evite lanzar excepciones más allá de los límites si desea una copia en línea.

    Preferir createServerFn cuando ya se utilice Start y los llamantes sean clientes tipados. No adopte Start solo para obtener este complemento para un formulario.

    Preferir tRPC cuando la API esté destinada a dispositivos móviles o socios. No instale tRPC únicamente para enviar una invitación por correo electrónico.

    No ejecute los tres métodos en la misma aplicación a menos que los esté midiendo.

    Límites estrictos

    JS-off en las acciones solo funciona cuando los campos son entradas con nombres reales. El estado que existe únicamente en React no superará esa prueba.

    El laboratorio midió los envíos, los tipos y las solicitudes con JS-off desactivado, no el TTI. Si el botón no hace nada cuando JavaScript está deshabilitado, a pesar de haber prometido lo contrario, la opción del backend es inadecuada para esa página.

    Ejecuta tres envíos: con los scripts del cliente habilitados, con ellos deshabilitados y simulando un fallo en la base de datos. Conserva aquellos casos en los que aún se muestre una línea de estado debajo del botón; ese conjunto constituye la evidencia.

    Tres rutas de invitación, tres formas de error

    En un formulario de invitación a la configuración, el mismo correo electrónico inválido generó tres interfaces diferentes: la acción se subrayó mediante useActionState, createServerFn lanzó un error en un límite genérico, y tRPC almacenó los detalles de Zod en error.data.zodError sin mapearlos. La latencia fue similar (~40 ms). El problema principal radicó en las diferencias entre las versiones del producto.

    Los envoltorios Invalid que solo permiten tostado borraron la identidad del campo. La solución duradera es utilizar un mapeador único:

    function fieldErrors(err: unknown): Record<string, string> {
      if (err && typeof err === "object" && "issues" in err) {
        const issues = (err as { issues: { path: (string|number)[]; message: string }[] }).issues;
        return Object.fromEntries(issues.map((i) => [String(i.path[0]), i.message]));
      }
      return { form: "Could not send invite" };
    }
    

    Cada backend termina con ese mapeador antes de llegar a la interfaz de usuario. Las direcciones de correo electrónico inválidas siempre se resaltan. Mantenga las acciones en la página de configuración Next, createServerFn en la sección Iniciar experimento, y tRPC ya está funcionando en el worker móvil. Varios backends son aceptables siempre que compartan esquema y mapeador; no lo son cuando hay múltiples formatos de error.

    Reproducción

    Envíe un campo vacío, nope, y luego una dirección válida. Capture el JSON o el estado de la acción. Si la ruta del mensaje difiere, habrá enviado tres productos diferentes.

    Ejemplo funcional: un esquema, tres entradas

    export const Invite = z.object({
      email: z.string().email(),
      role: z.enum(["admin", "member"]),
    });
    export type Invite = z.infer<typeof Invite>;
    

    Acción del servidor:

    "use server";
    export async function inviteAction(_prev: unknown, form: FormData) {
      const parsed = Invite.safeParse({
        email: form.get("email"),
        role: form.get("role"),
      });
      if (!parsed.success) return { errors: fieldErrors(parsed.error) };
      await sendInvite(parsed.data);
      return { errors: {} };
    }
    

    tRPC:

    invite: publicProcedure.input(Invite).mutation(({ input }) => sendInvite(input)),
    

    createServerFn:

    export const inviteFn = createServerFn({ method: "POST" })
      .validator(Invite.parse)
      .handler(({ data }) => sendInvite(data));
    

    La interfaz de usuario siempre lee errors.email. Los servidores backend que no pueden generar esa clave no se incluyen con este formulario.

    Guía para el próximo ingeniero

    El esquema en un archivo. El mapeador en otro archivo. Solo se permiten nuevos transportes si terminan con ese mapeador. Los errores que solo se muestran como notificaciones no cuentan. Mantenga la opción “invite” en la ruta de acción y “mobile” en tRPC. Evite agregar un cuarto transporte solo porque el artículo de esta semana elogió a GraphQL.

    Comandos para la sesión de laboratorio

    Registre las versiones de la cadena de herramientas antes de obtener resultados:

    node -v
    pnpm exec tsc -v
    pnpm exec next --version
    

    Luego inicie la aplicación:

    pnpm exec next dev
    

    Navegue por /, /invoices, /invoices/1, /settings y vuelva atrás. Conserve los registros de DevTools. Ejecute la verificación de tipos:

    pnpm exec tsc --noEmit --pretty false
    echo $?
    

    Código de salida cero significa permiso para inspeccionar el tiempo de ejecución, no prueba de calidad del producto. Ejecute nuevamente la matriz JS-on / JS-off / throw en su máquina: el calor, la RAM y las pestañas de Chrome afectan más los tiempos que una actualización menor del framework.

    Mantenga una nota de una línea con la solución fallida (Probé X, pero aún se observó Y) para que el cuaderno de notas siga siendo evidencia y no un folleto.

    Diario de fallos y lista de verificación

    tRPC requería error.data.zodError.fieldErrors.email[0]. Las acciones necesitaban state.errors.email. Al inicio se lanzó un ZodError en bruto. Unifíquese en errors.email: string; cualquier otra cosa es un error del mapeador.

    Apareció una cuarta ruta fetch('/api/invite') que fue eliminada con un comentario que apuntaba a inviteAction. Los comentarios son mejores que los cuartos mapeadores.

    Antes de agregar un transporte:

    • Importe el esquema compartido; no lo copie
  • Los fallos devuelven errors.email como cadena de texto
  • Un camino exitoso emite una sola invitación, nunca una copia por medio de transporte
  • Al ingresar nope aún se dibuja el subrayado en el campo que ven los usuarios
  • No existe una solución alternativa basada únicamente en notificaciones que oculte el camino
  • Si deja cualquier casilla sin marcar, el cambio se considerará un producto distinto, no un transporte alternativo.

    Nota proveniente del tráfico en producción

    La función de invitaciones ha generado tres servidores backend debido a la existencia de tres clientes: Next settings, Start experiment y móvil. Los usuarios ingresan un correo electrónico. La falta de subrayados genera tickets del tipo “dijo que está bien” cuando los operarios descartan posteriormente las solicitudes inválidas. Haga un seguimiento de las invitaciones enviadas en comparación con las destinadas. El producto es el mapeador; el transporte son los mecanismos de implementación. Cuando surja la tentación de usar GraphQL, vuelva a examinar las tres formas posibles de error y evítilas.

    El valor del experimento radica en la matriz de comparación, no en un distintivo para el ganador. Identifique qué ruta sobrevive sin JavaScript, cuál devuelve errores basados en claves de campo sin mecanismos de escape del mapeador, y cuál ya coincide con los clientes que usted maneja. Revise esa matriz cada vez que alguien proponga un endpoint de invitación “solo uno más”.

    Lecturas relacionadas