5.3 Form & FormError
5.3 Form & FormError
In deze appendix bespreken we hoe de Form en FormError componenten in elkaar zitten.
Form
De Form component wordt gebruikt om form actions te combineren met React Hook Form. De component heeft twee verplichte properties nodig, hookForm en action.
De hookForm property is het resultaat van de useForm functie uit React Hook Form, om dit te typen gebruiken we de UseFormReturn interface die geëxporteerd wordt door React Hook Form. Aangezien elk formulier andere elementen bevat, moeten we een generische parameter toevoegen die de properties in het formulier beschrijft. Verder is het mogelijk dat een schema validatie de form properties transformeert naar een ander object, ook hiervoor verwacht hookform een generische parameter. Tenslotte is er nog een derde generic nodig, die we nergens in deze component gebruiken en de defaultvalue unknown geven.
Om geen TypeScript fouten te krijgen moet de eerste generische parameter overërven van FieldValues (dit is zo gedefinieerd in React Hook Form).
import type {FieldValues, UseFormReturn} from 'react-hook-form'
interface FormProps<T extends FieldValues, TContext = unknown, TTransformedValues = TFieldValues> {
hookForm: UseFormReturn<TFieldValues, TContext, TTransformedValues>
}De signatuur van de action property komt niet overeen met de server actions. In deze cursus wrappen we elke server action in useActionState, die een functie teruggeeft die slechts één parameter heeft van het FormData type.
Verder moeten we ook overerven van de FormHTMLAttributes<HTMLFormElement> interface zodat de component alle attributen van een <form> tag accepteert. Tenslotte moeten we ook overerven van de PropsWithChildren interface zodat de formulierelementen doorgegeven kunnen worden.
import type {FormHTMLAttributes} from 'react'
interface FormProps<T extends FieldValues> extends PropsWithChildren, FormHTMLAttributes<HTMLFormElement> {
hookForm: UseFormReturn<TFieldValues, TContext, TTransformedValues>
action: (data: FormData) => void
}Vervolgens kunnen we deze properties gebruiken om een formulier te renderen waaraan de action gekoppeld is. Om de hook form informatie door te geven aan de kinderen van het formulier gebruiken we de FormProvider uit React Hook Form.
import {FormProvider} from 'react-hook-form'
function Form<TFieldValues extends FieldValues, TContext = unknown, TTransformedValues = TFieldValues>({
children,
action,
hookForm,
...formAttributes
}: FormProps<TFieldValues, TContext, TTransformedValues>) {
return (
<FormProvider {...hookForm}>
<form action={action} {...formAttributes}>
{children}
</form>
</FormProvider>
)
}Alhoewel bovenstaande code een goed begin is, moeten we de koppeling met React Hook Form nog in orde brengen. Dit kan via de handleSubmit functie uit React Hook Form. De eerste keer dat de functie opgeroepen wordt moet de default actie (het formulier indienen) geannuleerd worden zodat React Hook Form het formulier kan valideren.
Via de hasBeenValidated variabele houden we bij of het formulier al dan niet gevalideerd is. Aangezien deze variable geen re-render mag triggeren, maar wel persistent moet zijn doorheen de verschillende renders van het formulier, gebruiken we de useRef hook.
Na een succesvolle validatie moet het formulier opnieuw ingediend worden, maar dit keer moet de action opgeroepen worden. Om het formulier de tweede keer programmatorisch in te dienen, moeten de methodes van het <form> element gebruikt worden. Via de useRef hook krijgen we hier toegang toe.
De requestSubmit methode stuurt het formulier opnieuw in, maar we kunnen deze methode niet synchroon oproepen in de event handler van de onSubmit property. Via een timeout van 0 seconden werkt het wel. De aanpassing aan de hasBeenValidated variabele gebeurt pas nadat de event handler afgehandeld is. Als we geen timeout voorzien ziet React de tweede submit als een onderdeel van de eerste submit en wordt de waarde in useRef dus niet aangepast. Door een timeout van 0 seconden te voorzien, wordt de tweede submit op de message queue geplaatst en aangezien de aanpassing aan de hasBeenValidated variabele daar al op stond, wordt de timeout na deze aanpassing geplaatst.
function Form<TFieldValues extends FieldValues, TContext = unknown, TTransformedValues = TFieldValues>({
children,
action,
hookForm,
...formAttributes
}: FormProps<TFieldValues, TContext, TTransformedValues>) {
const {handleSubmit} = hookForm
const formRef = useRef<HTMLFormElement>(null)
const hasBeenValidated = useRef<boolean>(false)
const onSubmitHandler: FormEventHandler = evt => {
if (!hasBeenValidated.current) {
// Als het formulier nog niet gevalideerd is aan de client zijde moet de default actie (submitting) geannuleerd
// worden.
evt.preventDefault()
// Valideer het formulier via react-hook-form.
void handleSubmit(() => {
hasBeenValidated.current = true
// Omdat een update van state en useRef async is, kunnen we hier niet onmiddellijk het formulier opnieuw
// indienen want dan wordt de update aan hasBeenValidated niet geregistreerd voordat de volgende submit
// afgehandeld is.
setTimeout(() => formRef.current?.requestSubmit(), 0)
})(evt)
} else {
// Reset zodat een volgende submit opnieuw gevalideerd moet worden.
hasBeenValidated.current = false
}
}
return (
<FormProvider {...hookForm}>
<form onSubmit={onSubmitHandler} ref={formRef} action={action} {...formAttributes}>
{children}
</form>
</FormProvider>
)
}id veld
Om de Form component te kunnen gebruiken om data te updaten moet er een inputelement aanwezig zijn waarin het id van de te updaten data ingevuld is. In plaats van dit steeds manueel toe te voegen als kind van het formulier, kunnen we dit automatiseren door een id property toe te voegen aan de Form component. Als deze property gedefinieerd is, tonen we een verborgen inputelement met de waarde van de id property.
interface FormProps<TFieldValues extends FieldValues, TContext = unknown, TTransformedValues = TFieldValues>
extends PropsWithChildren,
FormHTMLAttributes<HTMLFormElement> {
hookForm: UseFormReturn<TFieldValues, TContext, TTransformedValues>
action: (data: FormData) => void
id?: string
}
function Form<TFieldValues extends FieldValues, TContext = unknown, TTransformedValues = TFieldValues>({
children,
action,
hookForm,
id,
...formAttributes
}: FormProps<TFieldValues, TContext, TTransformedValues>) {
const {handleSubmit} = hookForm
const formRef = useRef<HTMLFormElement>(null)
const hasBeenValidated = useRef<boolean>(false)
const onSubmitHandler: FormEventHandler = evt => {/* ... */}
return (
<FormProvider {...hookForm}>
<form onSubmit={onSubmitHandler} ref={formRef} action={action} {...formAttributes}>
{id && <input type="hidden" {...hookForm.register('id' as FieldPath<TFieldValues>)} defaultValue={id} />}
{children}
</form>
</FormProvider>
)
}Algemene errors
In het geval er iets mis gaat op de server, kan een algemene error gegenereerd worden. Dit zijn geen validatiefouten, maar errors die door een intern probleem in de server, zoals een niet functionele databaseconnectie, gegenereerd worden.
We printen deze bovenaan het formulier uit.
interface FormProps<TFieldValues extends FieldValues, TContext = unknown, TTransformedValues = TFieldValues>
extends PropsWithChildren,
FormHTMLAttributes<HTMLFormElement> {
hookForm: UseFormReturn<TFieldValues, TContext, TTransformedValues>
action: (data: FormData) => void
id?: string
}
function Form<TFieldValues extends FieldValues, TContext = unknown, TTransformedValues = TFieldValues>({
children,
action,
hookForm,
id,
...formAttributes
}: FormProps<TFieldValues, TContext, TTransformedValues>) {
const {handleSubmit, formState} = hookForm
const formRef = useRef<HTMLFormElement>(null)
const hasBeenValidated = useRef<boolean>(false)
const onSubmitHandler: FormEventHandler = evt => {/* ... */}
return (
<FormProvider {...hookForm}>
<form ref={formRef} action={action} {...formAttributes} onSubmit={onSubmitHandler}>
{id && <input type="hidden" {...hookForm.register('id' as FieldPath<TFieldValues>)} defaultValue={id} />}
{formState.errors.root && (
<div className="border border-destructive p-2 rounded my-4 flex items-center gap-4">
<CircleX className="text-destructive w-20 self-start " />
{formState.errors.root.message}
</div>
)}
{children}
</FormProvider>
)
}FormError
De FormError component wordt gebruikt om foutmeldingen weer te geven die veroorzaakt worden door validatiefouten op een specifiek inputelement.
Aangezien de FormError component enkel foutmeldingen toont, is er slechts één property nodig. De naam van het inputelement.
Vervolgens kan de useFormContext hook uit React Hook Form om de status en eventuele foutmeldingen uit te lezen en vervolgens uit te printen.
import type {FunctionComponent} from 'react'
import {useFormContext} from 'react-hook-form'
interface FormErrorProps {
path: string
}
const FormError: FunctionComponent<FormErrorProps> = ({path}) => {
const {
formState: {errors: formErrors},
} = useFormContext()
const formError = path
// Het pad kan een composite key zijn zoals contactInfo.0.type
// In de errors worden foutmeldingen bewaard als {contactInfo: [{type: {message?: string}}]
.split('.')
// Itereer door elke segment in de composite key (of de volledige key als deze maar uit één deel bestaat) en
// lees telkens een dieper genest deel uit
.reduce((acc, key) => (acc ? (acc[key] as object) : {}), formErrors) as {
// Hook form kan fouten geven in complexere formaten, maar dan is in de huidige vorm van de cursus nooit het geval.
// Daarom casten we zodat we een gewone string kunnen uitprinten.
message?: string
}
return <div className="text-muted-foreground text-xs">{formError?.message ?? <span> </span>}</div>
}
export default FormError