Ga naar de hoofdinhoud

6. Forms & validatie

27-07-2023Ongeveer 32 minutenOngeveer 4741 woorden

6. Forms & validatie

In dit hoofdstuk bespreken we hoe we data kunnen valideren, zowel aan de server- als aan de clientzijde en hoe we deze validatie kunnen linken aan server functions en formulieren. Verder bespreken we enkele componenten en functies die door de docenten aangereikt worden en repetitieve code afzonderen en efficiënt te herbruiken.

Startbestanden

Bovenstaande startbestanden bevatten verschillende functies en componenten om het gebruik van server actions te optimaliseren en zo weinig mogelijk code dubbel te moeten schrijven. We bespreken de implementatie van deze functies en componenten niet in detail, maar gebruiken deze enkel doorheen dit hoofdstuk. De implementatie van deze functies en componenten is relatief complex, we verwijzen de gemotiveerde studenten door naar de appendix voor meer gedetailleerde informatie over de werking en implementatie van deze functies en componenten.

Validatieschema's

We gebruiken Zod om validatieschema's op te stellen voor elke server functie. Deze library is zeer flexibel en bevat heel wat ingebouwde methodes om schema's uit te breiden, aan te passen en te combineren.

We beginnen met een eenvoudig voorbeeld waarbij we een tag valideren dat bestaat uit een naam en een id. Het schema ziet er als volgt uit:

/src/schemas/tagSchemas.ts
import {z} from 'zod'

export const tagSchema = z.object({
  id: z.uuid(),
  name: z.string().min(3, {error: 'The name of the tag must be at least 3 characters long'}),
})

Alhoewel dit schema een tag volledig beschrijft, kunnen we het niet gebruiken om de invoer voor de createTagAction te valideren omdat het id pas beschikbaar is nadat een tag aangemaakt is. De validatie zou dus altijd falen.

Via de omit methode kunnen we een nieuw schema opstellen, dat allesbehalve het id overneemt van het tagSchema. Deze methode neemt een object als parameter dat aangeeft welke velden we willen weglaten.

/src/schemas/tagSchemas.ts
import {z} from 'zod'

export const tagSchema = z.object({
  id: z.uuid(),
  name: z.string().min(3, {error: 'The name of the tag must be at least 3 characters long'}),
})

export const createTagSchema = tagSchema.omit({id: true})

Dit nieuw schema wordt vervolgens gebruikt om de invoer van createTagAction te valideren. Zod voorziet de parse en safeParse methodes waarmee gecontroleerd kan worden of een object al dan niet voldoet aan een opgegeven schema. In deze cursus verkiezen we de safeParse methode omdat deze geen error opgooit als de invoer niet voldoet aan het schema, maar een object teruggeeft met success, data en error properties.

Stappen om onderstaande informatie zelf uit te testen

Om de structuur van de Zod errors zelf uit te testen, kan je de createTagAction aanpassen en het formulier vervolgens inzenden met een tag name die korter is dan 3 karakters.

/src/serverFunctions/tags.ts
import {safeParse} from 'zod'

export async function createTagAction(_prevData: unknown, formData: FormData): Promise<void> {
  const logger = await getLogger()
  logger.info('createTagAction called')

  const {data: newTag, error} = createTagSchema.safeParse(convertFormData(formData))

  if (error) {
    logger.error({msg: error.message})
    return
  }

  const profile = await getSessionProfileFromCookie()
  if (!profile) {
    logger.warn(`Unauthenticated user tried calling createTagAction`)
    return
  }

  await createTag({...newTag, userId: profile.id})

  revalidatePath('/tags')
  logger.info('createTagAction completed successfully')
}

:::

Het error veld heeft onderstaande structuur.

[
  {
    "expected": "string",
    "code": "invalid_type",
    "path": [
      "name"
    ],
    "message": "Invalid input: expected string, received undefined"
  }
]

Het is duidelijk dat deze uitvoer te veel informatie bevat en niet eenvoudig weer te geven is op de client. Voordat we de foutmelding doorgeven naar de client moeten we deze omzetten naar een overzichtelijker formaat, dit kan via de flattenError methode uit Zod en leidt tot volgend resultaat:

{ name: [ 'The name of the tag must be at least 3 characters long' ] }

De errors worden dus weergeven als een object waarin de naam van een property gelinkt wordt aan een array van foutmeldingen (omdat er meerdere fouten kunnen optreden voor één veld).

Merk op dat we geen type meer moeten meegeven aan de convertFormData functie, de types worden automatisch afgeleid uit het Zod schema.

/src/serverFunctions/tags.ts
import {z} from 'zod'
import {createTagSchema} from '@/schemas/tagSchemas'

export async function createTagAction(_prevData: unknown, formData: FormData): Promise<void> {
  const logger = await getLogger()
  logger.info('createTagAction called')

  const {data: newTag, error} = createTagSchema.safeParse(convertFormData(formData))

  if (error) {
    return z.flattenError(error).fieldErrors
  }

  const profile = await getSessionProfileFromCookie()
  if (!profile) {
    logger.warn(`Unauthenticated user tried calling createTagAction`)
    return
  }

  await createTag({...newTag, userId: profile.id})

  revalidatePath('/tags')
  logger.info('createTagAction completed successfully')
}

Returntype definiëren

Bovenstaande code geeft nog foutmeldingen want het returntype is voorlopig void. De errors zijn eenvoudig te beschrijven als een map die de naam van het veld linkt aan een array van foutmeldingen of undefined als er geen fouten zijn.

/src/models/serverFunctions.ts
export type ValidationErrors = Record<string, string[] | undefined>

Naast de foutmeldingen is het handig als de action ook aangeeft of deze al dan niet succesvol was.

Tenslotte moet ook de ingezonden data teruggegeven worden, dit lijkt op het eerste zicht misschien onzinnig, maar React reset een formulier nadat het ingestuurd is, ongeacht of de actie succesvol was of niet. In het geval dat er geen validatiefouten zijn is dit geen probleem, maar als er wel fouten zijn is dit heel onaangenaam voor de gebruiker want alle informatie moet opnieuw ingevuld worden. Tenslotte voegen we ook de mogelijkheid toe om extra data terug te geven uit de server action. Alhoewel we deze functionaliteit niet nodig hebben in dit hoofdstuk, kan het, tijdens het bouwen van een complexer project, eventueel nuttig zijn.

/src/models/serverFunctions.ts
export type ValidationErrors = Record<string, string[] | undefined>
export type FormActionResponse<ReturnType = void> = {
  errors?: ValidationErrors
  success: boolean
  submittedData?: Record<string, string>
  data?: ReturnType
}

Met behulp van het nieuwe returntype werken we createTagAction af. Merk op dat zowel het type van _prevData als het returntype van de functie aangepast zijn, dit is nodig omdat de useActionState hook verwacht dat de twee types gelijk zijn.

/src/serverFunctions/tags.ts
export async function createTagAction(_prevData: FormActionResponse, formData: FormData): Promise<FormActionResponse> {
  const logger = await getLogger()
  logger.info('createTagAction called')

  const {data: newTag, error} = createTagSchema.safeParse(convertFormData(formData))

  if (error) {
    return {
      success: false,
      errors: z.flattenError(error).fieldErrors,
      submittedData: convertFormData(formData),
    }
  }

  const profile = await getSessionProfileFromCookie()
  if (!profile) {
    logger.warn(`Unauthenticated user tried calling createTagAction`)
    return {success: false, submittedData: convertFormData(formData)}
  }

  await createTag({...newTag, userId: profile.id})

  revalidatePath('/tags')
  logger.info('createTagAction completed successfully')

  return {success: true}
}

formAction

Alhoewel bovenstaande code bruikbaar is, wordt het snel vervelend om in elke action

  1. te controleren of er een profiel is;
  2. data te parsen;
  3. data te valideren;
  4. eventuele validatiefouten terug te geven ;
  5. als alles succesvol verloopt {success: true} terug te geven;
  6. algemene log statements toevoegen (action begint, eindigt, heeft validatiefouten, ...)

De startbestanden bevatten reeds een functie die al deze stappen bundelt.[1]

Begrip: protectedFormAction & publicFormAction

De protectedFormAction en publicFormAction functies, die aangeboden wordt door de backend-docenten, zijn functie die alle gemeenschappelijke logica voor een formAction afzonderen.

Deze functies converteren FormData naar een object (inclusief geneste properties en arrays), valideren de data vervolgens met een Zod schema en voeren tenslotte een opgegeven functie met backend-logica uit. Voor de protected variant wordt ook gecontroleerd of de gebruiker ingelogd is en eventueel of deze de juiste rol heeft. Tussen al deze stappen worden log statements toegevoegd.

Tijdens al deze acties worden eventuele foutmeldingen opgevangen en teruggegeven als {errors: {errors: ['...']}, submittedData: {...}} voor globale fouten of {errors: {field: ['...'],}} voor Zod-validatiefouten.

De functie geeft een nieuwe functie terug die onderstaande signatuur heeft:

(prevData: ServerFunctionResponse, formData: FormData): Promise<ServerFunctionResponse>

Met andere woorden een functie die opgeroepen kan worden via de useActionState hook.

De formAction functies hebben één parameter object met volgende properties:

  • schema: Een Zod schema dat de invoer valideert.
  • serverFn: Een functie die een ServerFunctionResponse object of void teruggeeft en backend-code uitvoert. Als serverFn een void functie is en geen fouten vertoond, geeft de formAction functie {success: true} terug. Als serverFn geen void functie is, wordt het resultaat van fn teruggegeven. serverFn heeft één parameter object met volgende properties:
    • data: De gevalideerde data.
    • profile: Het profiel van de huidige gebruiker. Enkel beschikbaar in de protectedFormAction variant.
    • logger: Een instantie van de Pino logger met requestId, userId, ...
  • globalErrorMessage: Een optionele foutboodschap die teruggegeven wordt in result.error.error in geval er zich een interne serverfout voordoet. Wordt standaard op 'Something went wrong, please ensure you are logged in and try again' gezet.
  • functionName: Een optionele naam voor de functie die gebruikt wordt tijdens het loggen. Wordt standaard op 'Server function' gezet.
  • requiredRoles: Een optionele array van de gebruikers rollen die toegang hebben tot de functie. Als de array niet meegegeven wordt, heeft elke gebruiker toegang. Enkel beschikbaar in de protectedFormAction variant.
// Een action waarvoor de gebruiker INGELOGD moet zijn.
export const createFoo = protectedFormAction({
  schema: zodSchema,
  serverFn: async ({data, profile}) => {
    // Database operaties en andere backend logica.
  },
  functionName: 'Create foo action',
})

// Een action waarvoor de gebruiker INGELOGD moet zijn en de Admin rol moet hebben.
export const createFoo = protectedFormAction({
  schema: zodSchema,
  serverFn: async ({data, profile}) => {
    // Database operaties en andere backend logica.
  },
  functionName: 'Create foo action',
  requiredRoles: [Role.Admin],
})

// Een action waarvoor de gebruiker NIET ingelogd moet zijn.
export const createFoo = publicFormAction({
  schema: zodSchema,
  serverFn: async ({data}) => {
    // Database operaties en andere backend logica.
  },
  functionName: 'Create foo action',
})

Met deze nieuwe functie kunnen we de createTag action herschrijven. Alle andere actions zijn al herschreven in de startbestanden.

/src/serverFunctions/tags.ts
export const createTagAction = protectedFormAction({
  schema: createTagSchema,
  serverFn: async ({data: newTag, profile}) => {
    await createTag({...newTag, userId: profile.id})
    revalidatePath('/tags')
  },
  functionName: 'Create tag action',
})

useActionState

De createTagAction functie was al gekoppeld aan het formulier op de create tag pagina. Momenteel geeft de code een foutmelding omdat we, door het toevoegen van de protectedFormAction wrapper, nu een ServerFunctionResponse object moeten gebruiken als initiële state (tweede parameter van useActionState).

De nieuwe vorm van de action geeft ook validatiefouten terug, die kunnen eenvoudig uitgelezen worden en getoond worden aan de gebruikers. De action geeft ook de ingestuurde data terug, deze moeten we uitlezen en via defaultValue koppelen aan het formulier, doen we dit niet, dan wordt het formulier leeggemaakt na een submit. Als het formulier niet correct ingevuld is, moeten we een foutmelding tonen, maar mag het formulier niet leeg gemaakt worden.

/src/app/(authenticated)/tags/tagForm.tsx
const TagForm: FunctionComponent = () => {
  const [actionResult, createTag] = useActionState(createTagAction, {success: false})

  return (
    <form action={createTag}>
      <div className="grid grid-cols-2 items-end gap-4">
        <div className="col-span-1 flex flex-col gap-4">
          <Label htmlFor="name">Title</Label>
          <Input id="name" name="name" type="text" placeholder="Work" />
          <p className="text-destructive">{actionResult?.errors?.name}</p>
        </div>
        <div className="col-span-1">
          <SubmitButtonWithLoading text="Create tag" loadingText="Creating tag..." />
        </div>
      </div>
    </form>
  )
}

Onderstaande video demonstreert dat de validatie nu correct werkt.

Figuur 1: Validatie voor het tag formulier

serverFunction

De formAction functie is handig voor acties die een formulier verwerken, maar niet voor gewone server functions die via een onClick, useEffect, ... opgeroepen worden. Voor deze situaties bevatten de startbestanden de serverFunction functie.

Begrip: protectedServerFunction & publicServerFunction

De protectedServerFunction en publicServerAction functies, die aangeboden wordt door de backend-docenten, zijn functies die alle gemeenschappelijke logica voor een server functie afzonderen.

De parameters van deze functie zijn dezelfde als voor de formAction functie, maar het returntype is anders:

type ServerFunction<Schema extends ZodType> = (data: z.infer<Schema>) => Promise<void>

Deze functie controleren de ingestuurde data met een Zod schema, geven eventuele foutmeldingen terug, voeren een opgegeven functie met backend code uit en logt dit proces. Voor de protected variant wordt ook gecontroleerd of de gebruiker ingelogd is en eventueel of deze de juiste rol heeft.

De serverFunction functies hebben één parameter object met volgende properties:

  • schema: Een Zod schema dat de invoer valideert.
  • serverFn: Een functie die een void teruggeeft en backend-code uitvoert. serverFn heeft één parameter object met volgende properties:
    • data: De gevalideerde data.
    • profile: Het profiel van de huidige gebruiker. Enkel beschikbaar in de protectedFormAction variant.
    • logger: Een instantie van de Pino logger met requestId, userId, ...
  • globalErrorMessage: Een optionele foutboodschap die teruggegeven wordt in result.error.error in geval er zich een interne serverfout voordoet. Wordt standaard op 'Something went wrong, please ensure you are logged in and try again' gezet.
  • functionName: Een optionele naam voor de functie die gebruikt wordt tijdens het loggen. Wordt standaard op 'Server function' gezet.
  • requiredRoles: Een optionele array van de gebruikers rollen die toegang hebben tot de functie. Als de array niet meegegeven wordt, heeft elke gebruiker toegang. Enkel beschikbaar in de protectedFormAction variant.
// Een server function waarvoor de gebruiker INGELOGD moet zijn.
export const deleteFoo = protectedServerFunction({
  schema: zodSchema,
  serverFn: async ({data, profile}) => {
    // Database operaties en andere backend logica.
  },
  functionName: 'Delete foo server function',
}

// Een action waarvoor de gebruiker INGELOGD moet zijn en de Admin rol moet hebben.
export const deleteFoo = protectedServerFunction({
  schema: zodSchema,
  serverFn: async ({data, profile}) => {
    // Database operaties en andere backend logica.
  },
  functionName: 'Delete foo server function',
  requiredRoles: [Role.Admin],
}

// Een action waarvoor de gebruiker NIET ingelogd moet zijn.
export const deleteFoo = = publicServerFunction({
  schema: zodSchema,
  serverFn: async ({data}) => {
    // Database operaties en andere backend logica.
  },
  functionName: 'Delete foo server function',
}

Via deze utility kunnen we de deleteTagServerFunction, linkTagsToContactServerFunction en unlinkTagsFromContactServerFunction functies herschrijven. Voor alle drie de functies moeten we nog een validatieschema schrijven. Alle andere server functions zijn al herschreven in de startbestanden.

/src/serverFunctions/tags.ts
export const deleteTagServerFunction = protectedServerFunction({
  schema: deleteTagSchema,
  serverFn: async ({data: {id}, profile}) => {
    await deleteTag(id, profile.id)
    revalidatePath('/tags')
  },
  functionName: 'Delete tag server function',
})

export const linkTagsToContactsServerFunction = protectedServerFunction({
  schema: linkUnlinkTagSchema,
  serverFn: async ({data: {tagId, contactIds}, profile}) => {
    await linkTagsToContact(tagId, contactIds, profile.id)
    revalidatePath(`/tags/${tagId}`)
  },
  functionName: 'Link tags to contacts server function',
})

export const unlinkTagsToContactsServerFunction = protectedServerFunction({
  schema: linkUnlinkTagSchema,
  serverFn: async ({data: {tagId, contactIds}, profile}) => {
    await disconnectTagsFromContact(tagId, contactIds, profile.id)
    revalidatePath(`/tags/${tagId}`)
  },
  functionName: 'Link tags to contacts server function',
})

Zod schema's uitbreiden

De startbestanden bevatten reeds een schema om een User object te valideren dat al gebruikt wordt in de bijhorende action en gekoppeld is aan het formulier. Het schema is echter ontoereikend voor het aanmaken van een account omdat de passwordConfirmation property ontbreekt. Via de extend methode kunnen we een bestaand Zod-schema uitbreiden met extra properties.

We herhalen hier de specifieke regels voor het wachtwoord (lengte, speciale tekens, hoofdletters, ...) niet, er is niets mis mee als we dit wel doen, maar dan zou de gebruiker meerdere foutmeldingen kunnen krijgen voor het passwordConfirmation veld terwijl we enkel geïnteresseerd zijn in de gelijkheid van het wachtwoord en de bevestiging. Via de refine[2] methode voegen een custom validatiefunctie toe die controleert dat de twee wachtwoorden gelijk zijn.

Deze methode heeft twee parameters, de eerste is een functie die de data valideert, de tweede een object met informatie over de foutmelding die getoond moet worden als de validatie faalt. In dit object gebruiken we de path eigenschap om de property waarover de foutmelding gaat aan te duiden, via de message property specifiëren we de foutmelding.

/src/lib/schemas/userSchemas.ts
export const registerSchema = userSchema
  .omit({id: true, role: true})
  .extend({
    passwordConfirmation: z.string(),
  })
  .refine(data => data.password === data.passwordConfirmation, {
    path: ['passwordConfirmation'],
    error: 'The password and confirmation do not match.',
  })
Figuur 2: Validatie van het registratieformulier

Info

De refine methode wordt niet overgenomen als je omit, extend of pick gebruikt. Via elk van deze methodes worden de velden in een schema aangepast, hierdoor kan het zijn dat de regine methode niet meer werkt, daarom moet deze voor elk afgeleid schema opnieuw gedefinieerd worden.

React hook form

React Hook Form[3] is een client-side library die gebruikt wordt om performante formulieren op te bouwen. Deze formulieren maken, per default, gebruik van uncontrolled components en vermijden het gebruik van state waar mogelijk. Dit zorgt ervoor dat de formulieren sneller werken en dat er minder re-renders nodig zijn, maar heeft als nadeel dat de source-of-truth nu bij de browser ligt en niet bij React.

Via de resolvers plugin is het mogelijk om Zod schema's te gebruiken om een formulier te valideren. Ook al hebben we na onderstaande aanpassing validatie aan de clientzijde, blijft het belangrijk om ook aan de serverkant validaties te doen.

Een gebruiker kan, mits de nodige technische kennis, de client-side validatie omzeilen en zo ongeldige data naar de server sturen, serverside validatie is dus noodzakelijk om de integriteit van de data te garanderen. Desalniettemin is het een goed idee om clientside validaties te doen. Hier zijn geen netwerk-requests voor nodig en we kunnen de gebruiker dus sneller feedback geven en daarbovenop wordt de server minder belast.

useForm

Begrip: useForm

De useForm hook is een hook die gebruikt wordt om formulieren te beheren met React Hook Form.

import {z} from 'zod'
import {useForm} from 'react-hook-form'
import {zodResolver} from '@hookform/resolvers/zod'

// Een Zod schema dat de invoer valideert.
const zodSchema = z.object({
  example: z.string()
})

const Component: FunctionComponent = () => {
  const {
      // Een functie die de inputnaam als argument neemt
      // en de nodige properties toevoegt aan een input element.
      register,
      // Een functie die de invoer controleert en als 
      // alles correct is een functie die de data verwerkt.
      handleSubmit,
      // Een functie die het formulier terugdraait naar
      // de defaultValues.
      reset,
      // Een object dat gebruikt kan worden om de formulierwaarden 
      // aan te passen.
      values,
      // Een object dat aan andere hooks doorgegeven moet worden. 
      control,
      // Informatie over het formulier, zoals de defaultwaarden, de 
      // validatieerrors, de gevalideerde velden, ...
      formState,
  } = useForm<z.infer<typeof zodSchema>>({
      defaulValues: {example: ''},
      resolver: zodResolver(zodSchema)
  });

  return (
    <form onSubmit={handleSubmit(data => console.log(data))}>
      <input {...register('example')} />
      <button type="submit">Submit</button>
    </form>
  );
};

Zoals hierboven beschreven kunnen we de useForm hook gebruiken om een formulier te bouwen met React Hook Form dat gevalideerd wordt met Zod. Deze code werkt echter niet met server actions, de startbestanden bevatten hier een oplossing voor, in de vorm van de Form component.[4]

Begrip: Form component

De Form component is een component die gebruikt wordt om formulieren te bouwen die react-hook-form en server actions gebruiken. Deze component is niet standaard beschikbaar, maar wordt aangereikt door de backend-docenten.

De Form component heeft twee optionele en twee verplichte properties:

  • hookForm: Het resultaat van de useForm hook.
  • action: De form action die uitgevoerd moet worden, i.e. het tweede element in de returnwaarde van de useFormState hook.
  • actionResult: Het resultaat van de form action, i.e. de returnwaarde van de form action, het eerste element in de returnwaarde van de useFormState hook. Deze optionele property wordt gebruikt om de form action te resetten na een succesvolle submit, als deze property ontbreekt, werkt het submitten nog steeds, maar wordt het formulier niet automatisch leeg gemaakt als de form action mislukt is.
  • id: Een optionele property die gebruikt kan worden om een id veld toe te voegen aan het formulier. Het veld wordt automatisch geregistreerd bij react-hook-form en kan gebruikt worden voor update formulieren.
import {z} from 'zod'
import {useForm} from 'react-hook-form'
import {zodResolver} from '@hookform/resolvers/zod'

const zodSchema = z.object({
  example: z.string()
})

const Component: FunctionComponent = () => {
  const [action, actionResult] = useFormState(someFormAction, {success: false});
  const hookForm = useForm<z.infer<typeof zodSchema>>({
      defaulValues: {example: ''},
      resolver: zodResolver(zodSchema)
  });

  return (
    <Form hookForm={hookForm} action={createTag} actionResult={actionResult}>
      <input {...register('example')} />
      <button type="submit">Submit</button>
    </Form>
  );
};

Naast de Form component gebruiken we ook de FormError component uit de startbestanden om foutmeldingen weer te geven, deze component heeft één property, de property-naam van het formulierelement waarvoor foutmeldingen getoond moeten worden.

Merk op dat elk formulierelement, zoals hierboven besproken, geregistreerd moet worden bij React Hook Form via de register methode. Verder gebruiken we ook de standardSchemaResolver in plaats van de zodSchemaResolver, beide resolvers produceren hetzelfde resultaat. Standard Schema is een algemene schema implementatie die gedeeld wordt door verschillende validatietools zoals Zod, ypu, joi, ... Door de standardSchemaResolver te gebruiken kunnen we in de toekomst dus eenvoudiger overschakelen naar een andere validatie-bibliotheek.

/src/app/(authenticated)/tags/tagForm.tsx
import {standardSchemaResolver} from '@hookform/resolvers/standard-schema'
import Form from '@/components/custom/form'
import FormError from '@/components/custom/formError'

const TagForm: FunctionComponent = () => {
  const [actionResult, createTag] = useActionState(createTagAction, {success: false})
  const hookForm = useForm({
    resolver: standardSchemaResolver(createTagSchema),
  })

  return (
    <Form hookForm={hookForm} action={createTag}>
      <div className="grid grid-cols-2 items-end gap-4">
        <div className="col-span-1 flex flex-col gap-4">
          <Label htmlFor="name">Title</Label>
          <Input
            {...hookForm.register('name')}
            id="name"
            name="name"
            type="text"
            placeholder="Work"
            defaultValue={actionResult?.submittedData?.name ?? ''}
          />
        </div>
        <div className="col-span-1">
          <SubmitButtonWithLoading text="Create tag" loadingText="Creating tag..." />
        </div>
      </div>
      <FormError path="name" />
    </Form>
  )
}

useZodValidatedForm

De startbestanden bevatten een hook die gebruikt kan worden om code die gemeenschappelijk is voor elk formulier af te zonderen en te herbruiken.

Begrip: useZodValidatedForm

De useZodValidatedForm hook is een hook die gebruikt wordt om de code die voor elk formulier geschreven moet worden af te zonderen en te herbruiken. Deze hook is niet standaard beschikbaar, maar wordt aangereikt door de backend-docenten.

De hook heeft drie parameters:

  • schema: Een Zod validatieschema waarmee in inhoud van het formulier gevalideerd wordt.
  • action: Een server (form) action die gebruikt wordt om de inhoud van het formulier te verwerken.
  • options: De optionele configuratie die doorgegeven wordt aan de useForm hook.

De hook geeft een array met vier elementen terug.

  • hookForm: De data die teruggegeven wordt door useForm
  • action: De functie die aan de action property van Form of <form> gekoppeld moet worden.
  • actionState: De state van de useActionState hook.
  • isPending: Een boolean die aangeeft of het formulier bezig is met een inzending te verwerken.
const Foo: FunctionComponent = () => {
  const [hookForm, createFoo, actionState, isPending] = useZodValidatedForm(createFooZodSchema, createFooAction)
  
  // Dit is de Form component die door docenten aangereikt wordt, niet de component uit react-hook-form of Next.
  return <Form hookForm={hookForm} action={createFoo}>
      ...
    </Form>
}

Via de useZodValidatedForm kunnen we de code van het formulier nog vereenvoudigen.

/src/app/(authenticated)/tags/tagForm.tsx
import {standardSchemaResolver} from '@hookform/resolvers/standard-schema'
import Form from '@/components/custom/form'
import FormError from '@/components/custom/formError'

const TagForm: FunctionComponent = () => {
  const [hookForm, createTag, actionResult] = useZodValidatedForm(createTagSchema, createTagAction)

  return (
    <Form hookForm={hookForm} action={createTag}>
      <div className="grid grid-cols-2 items-end gap-4">
        <div className="col-span-1 flex flex-col gap-4">
          <Label htmlFor="name">Title</Label>
          <Input
            {...hookForm.register('name')}
            id="name"
            name="name"
            type="text"
            placeholder="Work"
            defaultValue={actionResult?.submittedData?.name ?? ''}
          />
        </div>
        <div className="col-span-1">
          <SubmitButtonWithLoading text="Create tag" loadingText="Creating tag..." />
        </div>
      </div>
      <FormError path="name" />
    </Form>
  )
}

FormInput

Alhoewel deze code minder duplicatie bevat, kunnen we deze nog verkorten. Voor elke formulierelement moeten we steeds een label toevoegen, het formulierelement registreren bij hook-form en foutmelding tonen indien nodig. De startbestanden bevatten een FormInput component die deze dingen afzondert in een herbruikbare component.

Merk op dat we geen defaultwaarde meer meegeven, de Form component handelt dit ook voor ons af.

/src/app/(authenticated)/tags/tagForm.tsx
import {standardSchemaResolver} from '@hookform/resolvers/standard-schema'
import Form from '@/components/custom/form'

const TagForm: FunctionComponent = () => {
  const [hookForm, createTag] = useZodValidatedForm(createTagSchema, createTagAction)

  return (
    <Form hookForm={hookForm} action={createTag}>
      <div className="grid grid-cols-2 items-center gap-4">
        <FormInput name="name" label="Title" placeholder="Word" />
        <div className="col-span-1 -mt-2">
          <SubmitButtonWithLoading text="Create tag" loadingText="Creating tag..." />
        </div>
      </div>
    </Form>
  )
}

Complexe input componenten

Zoals in onderstaande video geïllustreerd wordt, werkt de validatie als het formulier de eerste keer ingestuurd wordt. Als er ongeldige gegevens ingevuld zijn, werkt de validatie geen tweede keer voor complexere inputs zoals een datepicker, combobox, ...

Figuur 3: Validatie mislukt tijdens de tweede inzending

De eerste keer dat het formulier gesubmit wordt, controleert Hook Form (via Zod) of er validatiefouten zijn. Als dit het geval is, wordt de input een controlled component in plaats van een uncontrolled component. Zo kan hookform validatiefouten verwijderen terwijl de gebruiker aan het typen is. Dit werkt echter enkel als we de onChange handler van Hook Form gebruiken (die automatisch toegevoegd wordt via de register functie).

Omdat we hier met een complexe component zitten, wordt die onChange functie echter nooit uitgevoerd. Er is wel een <input> element (dat is nodig om FormData in te sturen), maar de waarde hiervan wordt via de value property ingesteld door React, iets wat de onChange handler nooit triggert.

Om dit probleem op te lossen, gebruiken we de Controller uit Hook Form, dit is een wrapper die rond complexere input elementen gezet kan worden om deze toch te koppelen aan Hook Form. Hieronder tonen we enkel de DatePicker, de Combobox is reeds geïmplementeerd in de startbestanden.

/src/components/custom/datePicker.tsx
import {Controller} from 'react-hook-form'

const DatePicker: FunctionComponent<DatePickerProps> = ({defaultValue, ...inputProps}) => {
  const [date, setDate] = useState<Date | undefined>(defaultValue ?? undefined)
    
  return (
    <>
      <input type="hidden" value={(date ?? new Date()).toISOString()} {...inputProps} onChange={() => {}} />
      <Popover>
       <PopoverTrigger asChild>
         <Button
           variant="outline"
           className={cn('w-full justify-start text-left font-normal', !date && 'text-muted-foreground')}>
           <CalendarIcon className="mr-2 h-4 w-4" />
           {date ? format(date, 'PPP') : <span>Pick a date</span>}
         </Button>
        </PopoverTrigger>
        <PopoverContent className="w-auto p-0">
          <Controller
            name={inputProps.name}
            render={({field}) => (
              <Calendar
                mode="single"
                onSelect={newDate => {
                  setDate(newDate)
                  field.onChange(date ? new Date().toISOString() : newDate?.toISOString())
                }}
                autoFocus
              />
            )}
          />
        </PopoverContent>
      </Popover>
    </>
  )
}
Figuur 4: Validatie werkt tijdens de tweede inzending

Optionele velden in Zod

De startbestanden bevatten al schema's om een contact en de bijhorende createContactAction en updateContactAction te valideren. Er is nog een klein probleem met alle drie de schema's, de lastName, description en avatar properties zijn optioneel in de database en niet in het Zod schema. Om dit op te lossen gebruiken we de optional methode.

/src/lib/schemas/contactSchemas.ts
export const contactSchema = z.object({
  id: z.uuid(),
  userId: z.uuid(),
  firstName: z
    .string()
    .min(3, {error: 'The firstname must be at least 3 characters long.'})
    .max(255, {error: "The firstname can't be longer than 255 characters."}),
  lastName: z
    .string()
    .min(3, {error: 'The firstname must be at least 3 characters long.'})
    .max(255, {error: "The firstname can't be longer than 255 characters."})
    .optional(),
  description: z
    .string()
    .min(10, {error: 'The description must be at least 10 characters long.'})
    .max(255, {error: "The description can't be longer than 255 characters."})
    .optional(),
  avatar: z.string().min(10).max(255).optional(),
  contactInfo: z.array(
    z.object({
      type: string().min(3, {error: 'The contact type must be at least 3 characters long.'}),
      value: string().min(3, {error: 'The contact value must be at least 3 characters long.'}),
    }),
  ),
})

Alhoewel bovenstaand schema correct lijkt, is er nog een belangrijk probleem. Zoals onderstaand screenshot aantoont krijgen we nog een foutmelding te zien als we de achternaam niet invullen. We hebben wel aangegeven dat deze property optioneel is, maar omdat er een formulier element aanwezig is, en omdat we gebruik maken van FormData wordt er altijd een lege string verstuurd als de gebruiker niets ingegeven heeft. Als je een formulier uitleest via de value property krijg je altijd een string terug, nooit een undefined.

Validatiefout op de achternaam
Figuur 5: Validatiefout op de achternaam

Zod preprocessor

Om dit probleem op te lossen gebruiken we de preprocess functie uit Zod om een lege string te converteren naar undefined voordat de data gevalideerd wordt door safeParse of parse.

/src/lib/schemas/contactSchemas.ts
export const contactSchema = z.object({
  id: z.uuid(),
  userId: z.uuid(),
  firstName: z
    .string()
    .min(3, {error: 'The firstname must be at least 3 characters long.'})
    .max(255, {error: "The firstname can't be longer than 255 characters."}),
  lastName: z.preprocess(
    arg => (arg === '' ? undefined : arg),
    z
      .string()
      .min(3, {error: 'The firstname must be at least 3 characters long.'})
      .max(255, {error: "The firstname can't be longer than 255 characters."})
      .optional(),
  ),
  description: z.preprocess(
    arg => (arg === '' ? undefined : arg),
    z
      .string()
      .min(10, {error: 'The description must be at least 10 characters long.'})
      .max(255, {error: "The description can't be longer than 255 characters."})
      .optional(),
  ),
  avatar: z.preprocess(arg => (arg === '' ? undefined : arg), z.string().min(10).max(255).optional()),
  contactInfo: z.array(
    z.object({
      type: string().min(3, {error: 'The contact type must be at least 3 characters long.'}),
      value: string().min(3, {error: 'The contact value must be at least 3 characters long.'}),
    }),
  ),
})

Na deze aanpassing werkt de validatie correct en krijgen we geen foutmelding meer als de achternaam niet ingevuld is. Het is echter nog niet mogelijk om het formulier in te zenden omdat het formulier voor de contactInfo property hieronder pas geïmplementeerd wordt.

useFieldArray

Elk contact kan op nul of meer manieren gecontacteerd worden, ook dit deel van het formulier moet geïntegreerd worden in React Hook Form. Via de useFieldArray hook kunnen we een variabel aantal velden toevoegen aan een formulier. Elk van de velden is een object met één of meer properties, in dit geval type en value.

De hook neemt twee argumenten. Het eerste argument bevat de naam van het formulierveld waarmee de array geassocieerd is, in dit geval dus contactInfo. Het tweede argument is het control object dat teruggegeven wordt door de useForm hook.

De useFieldArray hook geeft, onder anderen, de fields, append, remove en insert methodes terug. De fields property bevat alle elementen in de contactInfo array, de append methode voegt een nieuw element toe, de delete methode verwijdert een element en de insert methode voegt een element toe op een specifieke positie.

/src/app/(authenticated)/contacts/new/page.tsx
import {useFieldArray} from 'react-hook-form'

const Page: FunctionComponent = () => {
  const [form, createContact] = useZodValidatedForm(createContactSchema, createContactAction, {
    defaultValues: {
      contactInfo: [{value: '', type: ''}],
    },
  })

  const contactInfo = useFieldArray({control: form.control, name: 'contactInfo'})

  return (
    <>
      <PageTitle>New Contact</PageTitle>
      <Form hookForm={form} className="mt-4 space-y-4" action={createContact}>
        <div className="flex gap-4">
          <FormInput name="firstName" label="First name" placeholder="John" />
          <FormInput name="lastName" label="Last name" placeholder="Doe" />
        </div>
        <div className="flex flex-col gap-2">
          <h5 className="text-xl">Contactinfo</h5>

          {contactInfo.fields.map((_, i) => (
            <div className="flex gap-2" key={i}>
              <FormInput name={`contactInfo.${i}.type`} placeholder="Contact type (e.g. email, phone)" />
              <FormInput name={`contactInfo.${i}.value`} placeholder="Contact info" />
            </div>
          ))}

          <LinedCircleIconButton
            Icon={Plus}
            disabled={false}
            onClick={evt => {
              evt.preventDefault()
              contactInfo.append({value: '', type: ''})
            }}
          />
        </div>

        <div className="flex justify-end mb-2">
          <SubmitButtonWithLoading loadingText="Creating contact" text="Create" />
        </div>
      </Form>

      <Link href="/contacts">
        <Button variant="destructive" className="w-full">
          Cancel
        </Button>
      </Link>
    </>
  )
}
Figuur 6: Contact aanmaken

useWatch

Zoals bovenstaande video aantoont, kunnen we nu wel een contact toevoegen en werkt alle validatie correct. Het is echter ook mogelijk om een nieuw contacttype toe te voegen zonder een waarde in te vullen in de voorgaande array elementen.

Om dit probleem op te lossen moeten we de knop deactiveren als het laatste element in de array geen waarde bevat. Aangezien React Hook Form uncontrolled components gebruikt kunnen we de disabled property niet gewoon instellen op basis van state zoals we dat in de frontend cursus zouden hebben.

De useWatch hook kan hierbij helpen, deze hook wordt gebruikt om te abonneren op wijzigingen in een formulierelement. Net zoals bij het gebruik van useState, zal de component bij het gebruik van useWatch re-renderen bij elke wijziging in de gespecifieerde state-waarde.

/src/app/(authenticated)/contacts/new/page.tsx
import {useFieldArray, useWatch} from 'react-hook-form'

const Page: FunctionComponent = () => {
  const [form, createContact] = useZodValidatedForm(createContactSchema, createContactAction, {
    defaultValues: {
      contactInfo: [{value: '', type: ''}],
    },
  })

  const contactInfo = useFieldArray({control: form.control, name: 'contactInfo'})
  const contactInfoData = useWatch({control: form.control, name: 'contactInfo'})

  return (
    <>
      <PageTitle>New Contact</PageTitle>
      <Form hookForm={form} className="mt-4 space-y-4" action={createContact}>
        <div className="flex gap-4">
          <FormInput name="firstName" label="First name" placeholder="John" />
          <FormInput name="lastName" label="Last name" placeholder="Doe" />
        </div>
        <div className="flex flex-col gap-2">
          <h5 className="text-xl">Contactinfo</h5>

          {contactInfo.fields.map((_, i) => (
            <div className="flex gap-2" key={i}>
              <FormInput name={`contactInfo.${i}.type`} placeholder="Contact type (e.g. email, phone)" />
              <FormInput name={`contactInfo.${i}.value`} placeholder="Contact info" />
            </div>
          ))}

          <LinedCircleIconButton
            Icon={Plus}
            disabled={contactInfoData.at(-1)?.type === '' || contactInfoData.at(-1)?.value === ''}
            onClick={evt => {
              evt.preventDefault()
              contactInfo.append({value: '', type: ''})
            }}
          />
        </div>

        <div className="flex justify-end mb-2">
          <SubmitButtonWithLoading loadingText="Creating contact" text="Create" />
        </div>
      </Form>

      <Link href="/contacts">
        <Button variant="destructive" className="w-full">
          Cancel
        </Button>
      </Link>
    </>
  )
}

Update acties

Het formulier om een contact te bewerken is bijna volledig afgewerkt in de startbestanden en is zo goed als volledig gelijk aan het create formulier. Er zijn nog twee belangrijke aanpassingen nodig, de defaultValues property van de useForm hook moet ingesteld worden op de waarden die uit de database uitgelezen worden en we moeten het id toevoegen aan het formulier.

In plaats van de default values in te stellen via de useForm hook (via useZodValidatedForm), kunnen we natuurlijk ook gebruik maken van de defaultValue property op elke FormInput component. Dit werkt echter relatief lastig voor de contactInfo property, de useForm hook is veel eenvoudiger.

/src/app/(authenticated)/contacts/[contactId]/edit/contactDetailForm.tsx
const ContactDetailForm: FunctionComponent<Contact> = contact => {
  const [form, updateContact] = useZodValidatedForm(updateContactSchema, updateContactAction, {
    defaultValues: {
      ...contact,
    },
  })
  const contactInfo = useFieldArray({control: form.control, name: 'contactInfo'})
  const contactInfoData = useWatch({control: form.control, name: 'contactInfo'})

  const [isPending, startTransition] = useTransition()

  return (
    <Form hookForm={form} action={updateContact}>
      ...
    </Form>
  )
}

id input

De ingestuurde FormData moet het id bevatten van het contact dat bewerkt wordt, anders weten we niet welke rij in de database aangepast moet worden.

De Form component heeft een optionele property id waarmee we dit probleem kunnen oplossen. Als deze property meegegeven wordt, wordt een nieuw onzichtbaar input-field toegevoegd met id als naam en als value de waarde van de id property.

De nieuwe input wordt gerenderd als kind van het formulier en wordt, bij een inzending, dus ook meegezonden met de FormData.

/src/app/(authenticated)/contacts/[contactId]/edit/contactDetailForm.tsx
const ContactDetailForm: FunctionComponent<Contact> = contact => {
  const [form, updateContact] = useZodValidatedForm(updateContactSchema, updateContactAction, {
    defaultValues: {
      ...contact,
    },
  })
  const contactInfo = useFieldArray({control: form.control, name: 'contactInfo'})
  const contactInfoData = useWatch({control: form.control, name: 'contactInfo'})

  const [isPending, startTransition] = useTransition()

  return (
    <Form hookForm={form} action={updateContact} id={contact?.id}>
      ...
    </Form>
  )
}

API Routes

API Routes moeten, net zoals server functions, validatie en authenticatie toevoegen aan elk request. Aangezien deze stappen repetitief zijn, bevatten de startbestanden een wrapper functie die deze stappen afzondert.

Begrip: protectedApiRoute & publicApiRoute

De protectedApiRoute en publicApiRoute functies, die aangeboden wordt door de backend-docenten, zijn functies die alle gemeenschappelijke logica voor een API route afzonderen.

Deze functies controleren de ingestuurde data met een Zod schema, geven eventuele foutmeldingen terug, voeren een opgegeven functie met backend code uit en logt dit proces. Voor de protected variant wordt ook gecontroleerd of de gebruiker ingelogd is en eventueel of deze de juiste rol heeft. Deze controle kan zowel stateful als stateless uitgevoerd worden, via een secure HTTP-only session cookie of via een bearer token in de authentication header.

De apiRoute functies hebben één parameter object met volgende properties:

  • schema: Een Zod schema dat de invoer valideert (uit het request body of de query parameters)
  • routeFn: Een functie die een void, NextResponse teruggeeft (al dan niet in een Promise) en backend-code uitvoert. routeFn heeft twee parameter objecten met volgende properties:
    • Parameter 1: Context
      • data: De gevalideerde data.
      • profile: Het profiel van de huidige gebruiker. Enkel beschikbaar in de protectedApiRoute variant.
      • logger: Een instantie van de Pino logger met requestId, userId, ...
    • Parameter 2: Route parameters
      • Een object met de parameters die gedefinieerd worden als onderdeel van het pad in de URL.
  • type: Een optionele parameter die de databron aanduidt, er zijn drie mogelijke opties:
    • body: Data wordt ingestuurd als een JSON-object in de body van een POST, PUT of PATCH request. Dit is de defaultwaarde.
    • form: Data wordt ingestuurd als een form encoded data in de body van een POST, PUT of PATCH request.
    • searchParams: Data wordt ingestuurd als searchparameters in de URL van het request.
  • authenticationType: Een optionele parameter die aangeeft hoe de gebruiker geauthenticeerd moet worden, de mogelijke opties zijn:
    • jwt: Gebruik een JWT token die doorgegeven wordt als een bearer token in de authentication header. Dit is de defaultwaarde.
    • cookie: Gebruik een secure HTTP-only session cookie met een JWT token als value.
  • requiredRoles: Een optionele array van de gebruikers rollen die toegang hebben tot de functie. Als de array niet meegegeven wordt, heeft elke gebruiker toegang. Enkel beschikbaar in de protectedFormAction variant.
// Een API route waarvoor de gebruiker INGELOGD moet zijn (via een JWT in de headers).
export const GET = protectedApiRoute({
  routeFn: async ({data, profile, logger}) => {
    // Database operaties en andere backend logica.
  },
}

// Een API route waarvoor de gebruiker INGELOGD moet zijn (via een session cookie).
export const GET = protectedApiRoute({
  routeFn: async ({data, profile, logger}) => {
    // Database operaties en andere backend logica.
  },
  authenticationType: 'cookie',
}

// Een action waarvoor de gebruiker INGELOGD moet zijn (via een JWT in de headers) en de Admin rol moet hebben.
// Daarnaast moet er data ingestuurd worden via een JSON object in de body van het request en moet er een route parameter
// meegegeven worden.  
export const PUT = protectedApiRoute({
  schema: zodSchema,
  routeFn: async ({data, profile, logger}, {fooId}: {fooId: string}) => {
    // Database operaties en andere backend logica.
  },
  requiredRoles: [Role.Admin],
}

// Een action waarvoor de gebruiker NIET ingelogd moet zijn.
export const GET = = publicApiRoute({
  routeFn: async ({data, logger}) => {
    // Database operaties en andere backend logica.
  },
}

Deze helper functies kunnen gebruikt worden om de API routes in het voorbeeld te herschrijven:

/api/auth/route.ts
export const POST = publicApiRoute({
    routeFn: async ({data, logger}) => {
        const user = await getUserByEmail(data.email)
        const timingSafePassword = `${hashOptions.iterations}$${hashOptions.keyLength}$preventTimingBasedAttacks123$${getSalt()}`
        const isValidPassword = verifyPassword(user?.password ?? timingSafePassword, data.password)

        if (!isValidPassword) {
            logger.warn(`Failed sign in attempt for ${data.email}.`)
            return unauthorized()
        }

        logger.info(`Successful authentication request for ${user!.id}`)

        const token = createJwtToken(user!)

        return ok({token})
    },
    schema: signInSchema,
})

Voorbeeldcode

Uitgewerkt lesvoorbeeld met commentaar


  1. De formAction functie is complex, we verwijzen de geïnteresseerde lezer door naar de appendix voor meer informatie over de implementatie. ↩︎

  2. De refine methode is relatief eenvoudig, als er meer complexe validaties nodig zijn kan de superRefine gebruikt worden. ↩︎

  3. React Hook Form werkt ook perfect in React Native applicaties, hier zijn enkele kleine aanpassingen in de code voor nodig, meer informatie is te vinden in de documentatie. ↩︎

  4. De implementatie van de Form component valt buiten de scope van deze les, we verwijzen de geïnteresseerde lezer door naar de appendix voor meer informatie. ↩︎