Im zweiten Teil der Artikelserie zu Next.js 14 optimieren wir das zuvor mit React Server Components erstellte Formular [1] und erweitern damit unseren Blick.
Die Themen dieses Artikels lauten: Fehlerbehandlung, Field Errors, Toasts für Nutzerfeedback, Progressive Enhancement, das Zurücksetzen eines Formulars und weitere Parameterübergabe.
Tauchen wir als Erstes tiefer in das Thema Fehlerbehandlung ein. Zunächst möchten wir genauer festlegen, wie (Erfolg/Fehler) und wo (inline/toast) Meldungen angezeigt werden. Dazu legen wir die Datei src/utils/to-form-state.ts an, die Hilfsfunktionen implementiert (Listing 1). Wir beginnen mit der Funktion fromErrorToFormState, die Fehler entgegennimmt und ein State-Objekt zurückgibt. Das zurückgegebene Objekt verwenden wir, um Feedback zu erzeugen. Die Fehler differenzieren wir anhand des Typs. Achtung: Im Fall eines Zod-Fehlers geben wir nur die erste Meldung des Zod-Validierungsfehlers zurück. Das ist eine Vereinfachung, da das Fehlerobjekt verschachtelt ist und wir später sämtliche Fehlermeldungen pro Formularfeld abrufen möchten. Doch der Reihe nach!
Listing 1
import { ZodError } from 'zod';
export type FormState = {
message: string;
};
export const fromErrorToFormState = (error: unknown) => {
// if validation error with Zod, return first error message
if (error instanceof ZodError) {
return {
message: error.errors[0].message,
};
// if another error instance, return error message
// e.g. database error
} else if (error instanceof Error) {
return {
message: error.message,
};
// if not an error instance but something else crashed
// return generic error message
} else {
return {
message: 'An unknown error occurred',
};
}
};
Wir verwenden die neue Utility-Funktion in der Server Action, in der wir die Message-Entität erstellen. Nicht zu vergessen ist der Import des FormState aus der zuvor erstellten Datei. Dieser soll nicht weiter in der Action-Datei enthalten sein (Listing 2). Nach diesen Änderungen erscheint der Validierungsfehler „String must contain at least 1 character(s)“ in der UI unseres Formulars, wenn man einen leeren Text sendet.
Listing 2
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { fromErrorToFormState } from '@/utils/to-form-state';
type Message = {
id: string;
text: string;
};
let messages: Message[] = [
{
id: crypto.randomUUID(),
text: 'First Message',
},
{
id: crypto.randomUUID(),
text: 'Second Message',
},
{
id: crypto.randomUUID(),
text: 'Third Message',
},
];
const createMessageSchema = z.object({
text: z.string().min(1).max(191),
});
export const getMessages = async (): Promise<Message[]> => {
await new Promise((resolve) => setTimeout(resolve, 250));
return Promise.resolve(messages);
};
type FormState = {
message: string;
};
export const createMessage = async (
formState: FormState,
formData: FormData
) => {
await new Promise((resolve) => setTimeout(resolve, 250));
try {
const { text } = createMessageSchema.parse({
text: formData.get('text'),
});
messages.push({
id: crypto.randomUUID(),
text,
});
} catch (error) {
return fromErrorToFormState(error);
}
revalidatePath('/');
return {
message: 'Message created',
};
};
Weil eine Nachricht ab jetzt aus title und text bestehen soll, fügen wir dem Formular ein weiteres Feld hinzu (Listing 3). So wird die Validierung auf Feldebene transparenter. Außerdem erweitern wir das Validierungsschema für die Server Action um die neue Eigenschaft title (Listing 4).
Listing 3
const MessageCreateForm = () => {
const [formState, action] = useFormState(createMessage, {
message: '',
});
return (
<form action={action} className="flex flex-col gap-y-2">
<label htmlFor="title">Title</label>
<input id="title" name="title" className="border-2" />
<label htmlFor="text">Text</label>
<textarea id="text" name="text" className="border-2" />
<SubmitButton label="Create" loading="Creating ..." />
<span className="font-bold">{formState.message}</span>
</form>
);
};
Listing 4
const createMessageSchema = z.object({
title: z.string().min(1).max(191),
text: z.string().min(1).max(191),
});
export const createMessage = async (
formState: FormState,
formData: FormData
) => {
await new Promise((resolve) => setTimeout(resolve, 250));
try {
const data = createMessageSchema.parse({
title: formData.get('title'),
text: formData.get('text'),
});
messages.push({
id: crypto.randomUUID(),
...data,
});
} catch (error) {
return fromErrorToFormState(error);
}
...
};
Betrachten wir jetzt die Feldfehler (Listing 5). Im Formular zeigen wir optionale Meldungen für jedes Formularfeld an. Wir verwenden den useFormState Hook, um die Informationen von der Server Action abzufragen. Bemerkung: In der derzeitigen Canary-Version von React heißt dieser Hook noch useFormState. Derzeit gibt es einen aktiven Pull Request [2], der den Namen des Hook in useActionState ändert und weitere Funktionen hinzufügt (z. B. isPending). Im Code wird der Anfangszustand durch eine Konstante ersetzt, welche später aus unserer neuen Utility-Datei genommen wird. Außerdem werden Feldfehler unter jedem Formularelement angezeigt. Aber nur, wenn es einen gibt, und dann auch nur der erste Fehler.
Listing 5
import { EMPTY_FORM_STATE } from '@/utils/to-form-state';
...
const MessageCreateForm = () => {
const [formState, action] = useFormState(
createMessage,
EMPTY_FORM_STATE
);
return (
<form action={action} className="flex flex-col gap-y-2">
<label htmlFor="title">Title</label>
<input id="title" name="title" className="border-2" />
<span className="text-xs text-red-400">
{formState.fieldErrors['title']?.[0]}
</span>
<label htmlFor="text">Text</label>
<textarea id="text" name="text" className="border-2" />
<span className="text-xs text-red-400">
{formState.fieldErrors['text']?.[0]}
</span>
<SubmitButton label="Create" loading="Creating ..." />
<span className="font-bold">{formState.message}</span>
</form>
);
};
Wir ergänzen in der Form-Utility-Datei die Konstante EMPTY_FORM_STATE und erweitern diese um die neuen Feldfehler (Listing 6). Vorbereitend für die spätere Verwendung fügen wir auch status und timestamp zum FormState hinzu. Wichtig ist der Teil, in dem wir den Zod-Fehler vereinfachen (error.flatten().fieldErrors). Das ist notwendig, da das Objekt verschachtelt ist.
Wir ordnen alle Fehlermeldungen den Formularfeldern zu. Nach der Umwandlung stellen die fieldErrors ein Verzeichnis dar, dessen Schlüssel die Feldnamen und die Werte der Arrays der Fehlermeldungen sind (Listing 7). In der...