Jak tworzyć formularze w next w app router?

Wstęp
Tworzenie formularzy to zadanie z którym prędzej czy później spotka się każdy developer. Obecnie mamy wiele metod tworzenia formularzy, które sprawiają, że developer może czuć się zagubiony. Trzeba podjąć decyzję, jaką bibilotekę do formularzy wybrać, zarządzać walidacją client-side albo server-side. Po wprowadzeniu app routera sprawa skomplikowała się jeszcze bardziej. Twórcy next'a wprowadzili server action, czyli funkcje asynchroniczne które uruchamiają się tylko na serwerze. Postanowiłem, że zbiorę to i uporządkuję w jednym miejscu.
W tym artykule będziemy:
- używać tailwinda i shadcn do stworzenia komponentów,
- zarządzać walidacją za pomocą zod,
- zarządzać stanem formularza za pomocą react-hook-form,
- omówimy różne metody wysyłania danych na serwer (json to api, form data to api, data object to server action, form data object to server action).
Zaczynamy od stworzenia aplikacji:
npx create-next-app@latest
Nadajemy nazwę.
Używamy TSa, tailwinda i appRoutera
Na resztę pytań z kreatora możesz odpowiedzieć dowolnie.
Inicjalizacja shadcn
Inicjalizujemy shadcn za pomocą komendy:
npx shadcn-ui@latest init
Ja wybrałem z kreatora następujące odpowiedzi:
Default
Slate
Yes
Po inicjalizacji powstanie plik components.json w root directory. To konfiguracja shadcn. Konfiguracja powinna wprowadzić zmiany w tailwind.config.ts i w css variables w pliku globals.css.
Za pomocą komendy
npx shadcn-ui@latest add form
dodajemy komponenty formularza takie jak: form, button, label. Shadcn kopiuje te komponenty do folderu components/ui, dzięki temu możemy je edytować. Razem z dodaniem form shadcn zainstalował react-hook-form i @hookform/resolvers.
Jeśli nie używasz shadcn musisz zainstalować je ręcznie.
Ostatni komponent którego nam brakuje to input. Dodamy go też z shadcn:
npx shadcn-ui@latest add input
Stwórzmy schemat walidacyjny:
import {z} from "zod"
export const registrationSchema = z.object({
first: z.string().trim().min(1, {
message: "First name is required",
}),
last: z.string().trim().min(1, {
message: "Last name is required",
}),
email: z.string().trim().email({
message: "Invalid email",
}),
});
export type RegistrationSchemaValues = z.infer<typeof registrationSchema>;
Najlepiej od razu wyciągnąć typ ze schemy. Dzięki temu, przy zmianie schemy TS poinformuje nas o zmianach w formularzach, gdzie jej używamy.
Jeśli komendy są dla Ciebie obce zapoznaj sie z dokumentacją zod
Stwórzmy komponent kliencki formularza:
"use client";
import { SubmitHandler, useForm } from "react-hook-form";
import {
RegistrationSchemaValues,
registrationSchema,
} from "../../validators/registrationSchema";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
export const RegistrationForm = () => {
const form = useForm<RegistrationSchemaValues>({
defaultValues: {
first: "",
last: "",
email: "",
},
resolver: zodResolver(registrationSchema),
});
const onSubmit: SubmitHandler<RegistrationSchemaValues> = (data) =>
console.log(data);
return (
<Form {...form}>
<form className="space-y-8" onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="first"
render={({ field }) => (
<FormItem>
<FormLabel>First</FormLabel>
<FormControl>
<Input placeholder="" {...field} />
</FormControl>
<FormDescription>First name</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="last"
render={({ field }) => (
<FormItem>
<FormLabel>Last</FormLabel>
<FormControl>
<Input placeholder="" {...field} />
</FormControl>
<FormDescription>Last name.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="" {...field} />
</FormControl>
<FormDescription>Email address.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
);
};
Komponentu możemy użyć na stronie:
import { RegistrationForm } from './RegistrationForm';
export default function ClientSideValidation() {
return (
<div className='mx-auto max-w-xl'>
<RegistrationForm />
</div>
);
}
Tym sposobem walidację po stronie klienta mamy ogarniętą.
Walidacja po stronie serwera
Na początek stwórzmy api route w api/register/route.ts
import { registrationSchema } from "@/validators/registrationSchema";
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
const data = await req.json();
let parsed = registrationSchema.safeParse(data);
if (parsed.success) {
return NextResponse.json({ message: "User registered", data: parsed.data });
} else {
return NextResponse.json({ error: parsed.error }, { status: 400 });
}
}
Możemy oczywiście strzelać do API w jakimkolwiek innym języku programowania. Next.js nie nakazuje nam wiązać api z wbudowanym mechanizmem api routes z naszej aplikacji. Ten api endpoint został stworzony tylko na potrzeby demonstracji.
Nasz endpoint jest zabezpieczony po stronie frontu i po stronie backendu. Funkcję onSubmit podłączamy do naszego api endpointu
const onSubmit = async (data: RegistrationSchemaValues) => ({
fetch("/api/register", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(data)
})
.then(response => response.json())
.then(data => console.log(data))
});
<form onSubmit={form.handleSubmit(onSubmit)}>
RegisterForm z walidacją na serwerze
Wysyłając ten formularz wysyłamy json do api więc kolejną metodę wysyłania danych mamy też za sobą. Uważam, że tę metodę będziemy nadal wykorzystywać najczęściej. Jedyna różnica w next z app routerem to taka, że komponent ten musimy oznaczyć jako kliencki. Reszta kodu "zaprawionym w boju" frontendowcom jest dobrze znana.
Form Data to API route
Zaczynamy od stworzenia server action w api/registerForm/route.ts
import { registrationSchema } from "@/validators/registrationSchema";
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
const formData = await req.formData(); //wyciągamy form data z zapytania
const data = Object.fromEntries(formData); // Zamieniamy form data w object
let parsed = registrationSchema.safeParse(data);
if (parsed.success) {
// Dodanie do bazy ;)
return NextResponse.json({ message: "User registered", data: parsed.data });
} else {
return NextResponse.json({ error: parsed.error }, { status: 400 });
}
}
Modyfikujemy onSubmit w RegistrationForm:
const onSubmit = async (data: RegistrationSchemaValues) => {
const formData = new FormData();
formData.append("first", data.first);
formData.append("last", data.last);
formData.append("email", data.email);
fetch("/api/registerForm", {
method: "POST",
body: formData,
})
.then((response) => response.json())
.then((data) => console.log(data));
};
Tym sposobem metodę Form Data to API route mamy też omówioną. Przejdźmy do ciekawszych metod ;)
Server action for Form
Server action może być stworzone w osobnym pliku lub w komponencie serwerowym, a następnie przekazane do komponentu klienckiego. Nie możemy jej stworzyć bezpośrednio w komponencie klienckim. Stwórzmy ją w komponencie serwerowym (trzeba ją oznaczyć dyrektywą use server):
import { z } from "zod";
import { registrationSchema } from "./registrationSchema";
export default function Page() {
const onDataAction = async (data: z.infer<typeof registrationSchema>) => {
"use server";
//data mają postać w formie JSON
const parsed = registrationSchema.safeParse(data);
if (parsed.success) {
console.log("User registered");
return { message: "User registered", user: parsed.data };
} else {
return {
message: "Invalid data",
issues: parsed.error.issues.map((issue) => issue.message),
}
}
}
return (
<div className="mx-auto max-w-xl">
<RegistrationForm onDataAction={onDataAction} />
</div>
);
}
W RegistrationForm musimy dodać nowy props i go otypować:
import { RegistrationSchemaValues } from "./registrationSchema";
export const RegistrationFormSAFD = ({
onDataAction,
}: {
onDataAction: (data: RegistrationSchemaValues) => Promise<{
message: string;
user?: RegistrationSchemaValues;
issues?: string[];
}>;
}) => {
const onSubmit = async (data: RegistrationSchemaValues) => {
//server validation in server action
const response = await onDataAction(data);
alert(JSON.stringify(response));
};
}
Metoda numer 3 za nami. Tym razem przyjrzeliśmy się metodzie Server action for Form.
Server action for handling data
Ta metoda ma jedną zaletę, której inne metody nie mają.
Formularz ten zadziała, nawet jeśli użytkownik ma wyłączony JS w przeglądarce. Jest wiele warunków, które muszą być spełnione, aby JS był włączony. Szczegółowe kroki przedstawia schemat na tej stronie
My developerzy zakładamy, że zawsze JS jest włączony, bo w większości przypadków tak jest. Czasem warto pamiętać, że JS może być wyłączony.
Tworzymy funkcję onFormAction w komponencie serwerowym.
const onFormAction = async (
prevState: {
message: string;
user?: z.infer<typeof schema>;
issues?: string[];
},
formData: FormData
) => {
"use server";
const data = Object.fromEntries(formData);
const parsed = await schema.safeParseAsync(data);
if (parsed.success) {
console.log("User registered");
return { message: "User registered", user: parsed.data };
} else {
return {
message: "Invalid data",
issues: parsed.error.issues.map((issue) => issue.message),
};
}
};
Importujemy nowego hooka useFormState i useRef, żeby móc zarządzać akcją i formularzem. Dodajemy propsa onFormAction i go typujemy.
import { useFormState } from "react-dom";
import { useRef } from "react";
export const RegistrationFormSAHD = ({
onFormAction,
}: {
onFormAction: (
prevState: {
message: string;
user?: RegistrationSchemaValues;
issues?: string[];
},
data: FormData
) => Promise<{
message: string;
user?: RegistrationSchemaValues;
issues?: string[];
}>;
}) => {
// hook ten przyjmuje jako pierwszy argument onFormAction i initialState. My ustawimy tutaj obiekt z message
const [state, formAction] = useFormState(onFormAction, {
message: ""
});
const formRef = useRef<HTMLFormElement>(null);
return (
<Form {...form}>
<div>{state?.message}</div> {/* stan z hooka useFormState */}
<form
ref={formRef}
action={formAction}
onSubmit={form.handleSubmit(() => formRef?.current?.submit())} //ref włącza walidację client side (o ile JS w przeglądarce jest włączony), a potem włącza formAction jeśli wszystko ok
className="space-y-8"
>
Teraz po kliknięciu submit włączy się formAction i wyślą się dane. Nadal mamy client-side validation, a walidacja po stronie serwera włącza się gdy coś pójdzie nie tak. Teraz, nawet bez JSa w przeglądarce walidacja będzie działała. Walidacja po dwóch stronach jest bardzo ważna. Zapewnia integralność danych nawet gdy JS jest wyłączony lub ktoś wysłał dane na endpoint z innego źródła niż formularz np. curlem
Osobiście uważam, że ze względu na stopień skomplikowania metoda ta, nie będzie często używana.
Możemy oczywiście zmodyfikować w tej metodzie formAction i dodać walidację na każde pole w formularzu i jego obsługę po stronie serwera.