Strona główna / Artykuły

Jeden formularz zaproszeń w Server Actions, createServerFn i tRPC

Jedna forma zaproszenia została stworzona na trzy sposoby: Next.js Server Actions, TanStack createServerFn oraz tRPC; porównano je pod kątem typów, plików do pobrania, zachowania bez JS oraz błędów Zod.

Wydawca: REACTAPP.TOP7 min czytania1626 słów

Źródła: One Form, Three Backends: Server Action, createServerFn, tRPC.

Trzy silniki backendowe, jedno pole na e-mail

email. Naciśnij „Zatwierdź”. Sukces to linia pod przyciskiem, a niepowodzenie również linia pod przyciskiem. To cała powierzchnia interfejsu produktu.

createServerFn oraz mutacja tRPC. Używany jest wspólny schemat Zod oraz wspólna kopia danych. To ta sama aplikacja z czterema trasami, plus drzewo Start, dzięki któremu createServerFn nie jest bezużyteczny. Manifesty mówiące o tym, który zestaw narzędzi jest „standardem na rok 2026”, nie pomagają w pracy formularza. Dla formularza ważne jest to, czy błędy przychodzą w postaci tekstowym, który można wyświetlić, czy typy danych przetrwają przeniesienie, oraz czy strona nadal funkcjonuje bez JavaScriptu po stronie klienta.

Co każdy zestaw narzędzi faktycznie obiecuje

W narzędziu Next należy oznaczyć funkcję za pomocą "use server" i skierować na nią właściwość action formularza — opcjonalnie poprzez useActionState dla stanów w oczekiwaniu i wyniku.

W narzędziu TanStack Start funkcja createServerFn zwraca typizowaną funkcję wywoływalną z mechanizmem walidacji. Aby ją wywołać przy wysyłce danych z tradycyjnego formularza, potrzebny jest własny adapter; domyślnie nie trafia ona jako akcja progresywnego formularza.

W tRPC definiuje się procedurę mutacji, którą wywołuje się z klienta. Działanie bez JavaScript nie jest częścią tego projektu.

To opublikowane kontrakty określiły ramy eksperymentu.

Schemat wykorzystywany wszędzie

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

Jeden schemat, trzy obsługi. Jeśli jakaś platforma wymusi drugą wersję tych samych zasad, to koszt pojawi się w raporcie wyników.

Ścieżka akcji serwera

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

Gdy JavaScript jest wyłączony, formularz nadal wysyła dane. To właśnie jest umowa Action, która została wykorzystana w tym laboratorium.

ścieżka createServerFn

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

Podczas uruchamiania formularz wywołuje funkcję inviteFn({ data: { email } }) z elementu onSubmit. Bez dodatkowych adapterów ta ścieżka wymaga JavaScripta. Stworzenie fałszywego obudowy typu progressive-enhancement i udawanie, że jest ona dostępna wraz z frameworkiem, sfałszowałoby porównanie.

ścieżka tRPC

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

W aplikacji klienckiej użyto funkcji api.invite.useMutation(). Typy dla pola input.email były doskonałe. Bez JavaScripta przycisk nie ma żadnego znaczenia.

Jak oceniono te trzy rozwiązania

Rodzaje. tRPC oraz createServerFn wywnioskowały dane wejściowe z Zod bez konieczności określania osobnego typu klienta. Funkcja serwera przyjmowała FormData i polegała na safeParse. To rozwiązanie jest bezpieczne, ale nie równoznaczne z wywołaniem klienta o określonym typie.

Pakiet. Wymagania dotyczące działania serwera prawie się nie zwiększyły. tRPC wykorzystuje już obowiązkowe elementy klienta i dostawcy, które musi spełniać interfejs API. createServerFn wykorzystuje funkcję klienta z biblioteki Start. Istniejąca implementacja tRPC sprawia, że operacje są tanie; natomiast nowa implementacja tRPC przeznaczona wyłącznie do obsługi jednego e-maila nie jest tak korzystna.

Rzucanie błędów. Gdy db.invites.create jest zmuszane do powrotu błędu:

  • Funkcja działania: wartości zwracane do useActionState są wyświetlane pod przyciskiem. Nieprzechwycony błąd trafia do mechanizmu obsługi błędów w Next, dopóki nie zostanie obsłużony.
  • createServerFn: obietnica jest odrzucana; należy to obsłużyć w funkcji onSubmit.
  • tRPC: isError w mutacji; ta sama logika mapowania.
  • JS wyłączone. Działanie zadziałało. Pozostałe dwa nie. To różnica w umowie, a nie ranking moralny. Strony ustawień marketingowych, które muszą funkcjonować bez JS, powinny używać Actions. Dashboardy, które już wymagają JS, mogą zignorować ten element.

    Rachunek poza schematem

    Adaptery pochłonęły cały wieczór, a nie Zod.

    Kształt błędu zmieniał się, aż został ustandaryzowany na { error?: string }. tRPC wolał error.message; należy to mapować raz. Nie pozwól, by trzy różne formy trafiły do akapitu pod przyciskiem.

    Autoryzacja różniła się: Actions korzystały z ciasteczek żądania dokumentu; tRPC używał kontekstu procedury; createServerFn wykorzystywało żądanie od Start. Wszystkie mogą być poprawne; tylko jedna odpowiada „wysłanemu formularzowi”.

    Odświeżanie pamięci podręcznej po sukcesie również się potroiło: działanie updateTag("invites"), tRPC utils.invites.list.invalidate() – rozpoczęcie unieważniania danych. Ta sama fraza, trzy warianty.

    Który mechanizm wybrać

    Należy preferować Server Action, gdy kontrola odbywa się za pomocą rzeczywistej formularza w Next i ważna jest nadal możliwość pracy bez JS. Otocz to funkcją useActionState, zwracaj błędy i unikaj przenoszenia ich poza granice, jeśli chcesz mieć kopię w tym samym miejscu.

    Należy preferować createServerFn, gdy już korzystasz z Start, a wywołujące funkcje to typowane klienty. Nie wprowadzaj Start tylko po to, by mieć ten pomocnik dla jednego formularza.

    Należy preferować tRPC, gdy API służy produktom mobilnym lub partnerom. Nie instaluj tRPC tylko po to, by wysłać jedno e-mail.

    Nie uruchamiaj wszystkich trzech w jednej aplikacji, chyba że je mierzysz.

    Ograniczenia

    JS-off w przypadku Actions działa tylko wtedy, gdy pola to rzeczywiste elementy wejściowe o określonych nazwach. Stan istniejący wyłącznie w React nie przetrwa tego testu.

    W laboratorium mierzono liczby prób wysłania danych, typy oraz wyniki działań JS-off—nie TTI. Jeśli przycisk nie robi nic po wyłączeniu JS, mimo obietnic inaczej, wybór backendu dla tej strony jest błędny.

    Zrób trzy próby wysłania danych: z włączonymi skryptami klienckimi, z wyłączonymi skryptami klienckimi oraz przy wymuszonej awarii bazy danych. Zachowaj te ścieżki, które nadal wyświetlają linię statusu pod przyciskiem—to właśnie one stanowią dowód.

    Trzy ścieżki zaproszeń, trzy rodzaje błędów

    Na formularzu zaproszeń do ustawień to samo nieprawidłowe adres e-mail spowodowało trzy różne interfejsy: akcja podkreślona za pomocą useActionState, createServerFn rzucił błędem w ogólnej strukturze obsługi błędów, a tRPC przechowywał szczegóły błędu Zod w polu error.data.zodError bez mapowania. Opóźnienie było podobne (~40ms). Głównym problemem była różnica w funkcjonalności produktu.

    Tylko wrapper typu Toast z wartością Invalid usuwały identyfikator pola. Trwałe rozwiązanie to jeden mapper:

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

    Każdy backend kończy się tym mapperem przed interfejsem użytkownika. Nieprawidłowy adres e-mail zawsze jest podkreślony. Zachowaj Actions na stronie ustawień Next, createServerFn przy rozpoczynaniu eksperymentu – tRPC w aplikacji mobilnej już funkcjonuje. Wystarczy kilka backendów, jeśli dzielą schemat i mapper; różne formy błędów nie są do przyjęcia.

    Odtworzenie problemu

    Złóż puste dane, nope, a następnie prawidłowy adres. Zapisz JSON lub stan działania. Jeśli ścieżka do wiadomości się różni, wysłałeś trzy różne produkty.

    Przykład działający: jeden schemat, trzy wpisy

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

    Działanie serwera:

    "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));
    

    Interfejs użytkownika zawsze odczytuje wartość errors.email. Serwery backendowe, które nie mogą wygenerować tej klucza, nie są dostępne za tym formularzem.

    Wskazówki dla następnego inżyniera

    Schemat w jednym pliku. Mapper w innym pliku. Nowe metody przesyłania danych są dozwolone tylko wtedy, gdy kończą się na tym mapperze. Błędy typu toast nie są dozwolone. Zachowaj ustawienie „invite” na ścieżce Action, a „mobile” przy tRPC. Unikaj dodawania czwartej metody przesyłania danych tylko dlatego, że artykuł z tego tygodnia chwali GraphQL.

    Polecenia podczas sesji laboratoryjnej

    Zapisz wersje narzędzi przed sprawdzaniem wyników:

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

    Następnie uruchom aplikację:

    pnpm exec next dev
    

    Przejdź po ścieżkach /, /invoices, /invoices/1, /settings i z powrotem. Zachowaj logi z DevTools. Uruchom sprawdzenie typów:

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

    Kod wyjścia zero oznacza uprawnienie do inspekcji w czasie rzeczywistym, a nie dowód jakości produktu. Uruchom ponownie testy JS-on / JS-off / throw matrix na swoim komputerze – temperatura, pamięć RAM oraz karty w Chrome znacząco wpływają na czasy wykonywania zadań bardziej niż drobne zmiany w frameworku.

    Zapisz jednozdaniową notatkę o nieudanej próbie naprawy (Próbowałem X, nadal mam problem Y), aby notatnik pozostał dowodem, a nie broszurą.

    Dziennik błędów i lista kontrolna

    tRPC wymagał wartości error.data.zodError.fieldErrors.email[0], natomiast funkcje potrzebowały state.errors.email. Zaczęło się od surowego obiektu ZodError; należy je spójnić pod względem struktury errors.email: string; wszystko inne to błąd w mechanizmie mapowania.

    Pojawiła się czwarta trasa fetch('/api/invite'), która została usunięta wraz z komentarzem wskazującym na inviteAction. Komentarze są lepsze od czwartych mechanizmów mapowania.

    Zanim dodasz nowy transport:

    • Imporтуj udostępnioną schematyzację; nie kopiuj jej
  • W przypadku błędów zwracany jest errors.email jako ciąg znaków
  • Uspieszna ścieżka wysyła jedno zaproszenie, nigdy nie jedną kopię na każdą metodę dostawy
  • Wpisanie nope nadal wyświetla podkreślenie w polu widocznym dla użytkowników
  • Nie ma alternatywy opartej wyłącznie na komunikacie toast, która ukryłaby ścieżkę
  • Jeśli jakikolwiek poligon pozostanie niezaznaczony, zmiana oznacza drugi produkt, a nie alternatywną metodę dostawy.

    Uwaga z ruchu produkcyjnego

    Ustawienia zaproszeń obejmują trzy systemy backendowe, ponieważ istnieją trzy typy klientów: ustawienia Next, rozpoczęcie eksperymentu oraz wersja mobilna. Użytkownicy wpisują adres e-mail. Brak podkreśleń powoduje tworzenie zgłoszeń typu „powiedziano, że wszystko w porządku”, gdy pracownicy później odrzucają nieważne dane. Należy obserwować wysłane zaproszenia w porównaniu z tymi zamierzonymi. Produkt to maper, a metoda dostawy to elementy techniczne realizujące przesyłanie danych. Gdy pojawi się pokusa związana z GraphQL, należy ponownie przejrzeć te trzy rodzaje błędów i od nich odstąpić.

    Wartością tego eksperymentu jest macierz porównawcza, a nie odznaka zwycięzcy. Zidentyfikuj, który ścieżka funkcjonuje bez JS, która zwraca błędy oparte na kluczach pól bez możliwości ucieczki poprzez mapowanie, oraz która już odpowiada klientom, którymi się opiekujesz. Sprawdź tę macierz za każdym razem, gdy ktoś proponuje dodatkowy punkt końcowy do zapraszania.