AccueilClientsExpertisesBlogOpen SourceJobsContact

27 septembre 2023

Next 13.4 : Tour d’horizon des server actions

9 minutes de lecture

Next 13.4 : Tour d’horizon des server actions

Après les composants serveurs et le nouveau système de routeur, Next continue sur sa lancée en introduisant les server actions dans sa version 13.4. Une nouveauté que nous avons testé sur notre projet open source Digest Club . C’est l’occasion de faire le point sur cette nouvelle approche de mutation des données proposée par Next.

⚠️ Cet article s'appuie sur les concepts de composants serveur et clients du nouveau routeur app de Next. Pour en savoir plus sur ces notions, vous pouvez consulter l'article de Baptiste qui traite de ce sujet ici.

Des échanges simplifiés avec la base de données

Les Servers Actions s'inscrivent dans une série continue d'améliorations visant à migrer autant que possible d’activité côté serveur :

  • Avec le système précédent de routeur pages et l’usage de getServerSideProps, Next avait ouvert la voie de la récupération de données côté serveur
  • Les composants serveur et le nouveau système de routeur app ont permis d'inclure les requêtes asynchrones directement à l’intérieur du composant
  • Avec les server actions c'est au tour des requêtes POST de passer côté serveur. On élimine donc la lourdeur d’une surcouche API pour gérer les mutations internes à l’application. Un simple appel de fonction suffit désormais.

GET et POST, tout le spectre des requêtes est couvert permettant ainsi de :

  • 🐘 Réduire la taille du bundle côté client
  • 🚀 Améliorer les performances de l’application en réduisant les échanges clients / serveur pour la lecture ou l’update de données
  • 💧 Réduire les temps de chargements en cascade de nos composants en préchargeant les données
  • ⏰ Gagner du temps en supprimant l'étape de mise en place de endpoint d'API dédiés pour les opérations CRUD internes à l'app Si la promesse est alléchante, qu’en est-il de la pratique ?

Pour mieux comprendre le fonctionnement des server actions, appuyons-nous sur un exemple concret dans notre application Digest Club : le formulaire permettant de modifier le nom de l'utilisateur connecté. DigestClub Mon Compte

La formule classique : react-query + endpoint API

DigestClub utilise le routeur app de Next. La page avec le formulaire qui affiche les informations de l’utilisateur est donc par défaut un composant serveur :

const AccountPage = async () => {
    const user = await getCurrentUser();
    if (!user) return notFound();

    return (
    <PageContainer>
        <SectionContainer
        title={`Hello ${user?.name || user?.email}`}
        >
            <AccountForm user={user!} />

La récupération de données se fait donc directement en asynchrone dans le composant parent. Mais pour le moment, notre formulaire met à jour les informations utilisateurs en envoyant une requête POST sur un endpoint dédié de l’API (ex : /api/user/[userId]). On utilise ici axios et react-query pour envoyer les données sur le serveur depuis le navigateur :

‘use client’

const AccountForm = ({ user }: Props) => {
    const { successToast, errorToast } = useCustomToast();
    const { mutate, isLoading } = useMutation(
        'user-update',
        (data: Partial<User>) => api.put(`/user/${user?.id}`, data),
        {
            onSuccess: () => successToast('Your account has been updated'),
        }
    );

    const handleSubmit = (e: React.FormEvent<Form>) => {
        const { name } = e.currentTarget.elements;
        mutate({name: name.value.trim()});
    };

    return (
        <form onSubmit={handleSubmit} >
            {/* Nos champs de formulaire */}

Si l'on ajoute la mise en place du endpoint dédié, le code nécessaire pour effectuer une seule mutation devient rapidement important. Voyons maintenant l’équivalent avec une server action.

La formule magique : Server action + composants serveur

La première étape consiste à activer la fonctionnalité des server actions, qui est encore en version expérimentale, en modifiant le fichier next.config.js :

module.exports = {
  experimental: {
    serverActions: true,
  },
}

Pour créer notre première server actions, il vous suffit de créer un fichier dans lequel on inclut la directive 'use server' en première ligne. Toute fonction déclarée dans ce fichier sera alors automatiquement executée côté serveur.

On ajoute ensuite la logique de notre fonction pour modifier les informations utilisateur en base :

'use server'

export default async function updateUser(formData: FormData): Promise<UpdateUserResult> {
  const session = await getSession()
  const updatedUser = await db.user.update({
    where: {
      id: session?.user?.id,
    },
    data: { name: formData?.get('name')?.trim() ?? '' },
  })
  revalidatePath('/account')
}

Une fois l’update réalisé, revalidatePath nous permettra de rafraîchir le cache et de mettre à jour les informations utilisateur dans notre composant parent en une seule requête.

📌 Les server actions sont composables, on crée ici un fichier à part pour les regrouper. Elles peuvent aussi être déclarées directement à l’intérieur de nos composants serveurs. Dans ce cas là la mention ‘use server’ sera incluse dans le corps de la fonction elle-même.

Du côté de notre formulaire, plus besoin de react-query on peut donc supprimer la mutation et la directive ‘use client’. On passe ensuite notre server action en attribut “action” du formulaire. À la validation c’est cette fonction qui sera appelée directement sur le serveur.

import updateUser from '@/actions/update-user';

const AccountForm = ({ user }: Props) => {
    return (
        <form action={updateUser}>
            {/* Nos champs de formulaire */}
            <div>
                <Button type="submit"> Save </Button>

On notera par ailleurs que l’usage de notre server action au sein de composants serveur rend maintenant notre formulaire totalement fonctionnel même sans javascript, suivant les principes de l’Amélioration Progressive (Progressive Enhancement).

À la soumission du formulaire, on observe bien l'appel à la fonction partir avec les données de notre formulaire accompagnées d’un ACTION_ID généré automatiquement, sans avoir jamais eu à gérer la partie API. Next s’occupe de tout 😌

Afficher un loader avec useFormStatus

Donner un feedback à nos utilisateurs, le temps que notre serveur traite la requête, est toujours apprécié. L’usage de notre server action dans notre formulaire nous permet d’avoir accès au hook expérimental de React useFormStatus. Ce hook devra être utilisé dans un composant client qui doit impérativement être enfant du formulaire et renvoie le statut “pending” le temps que la server action traite les données de notre formulaire :

'use client'
import { experimental_useFormStatus as useFormStatus } from 'react-dom'
import Button from '../Button'

const FormButton = () => {
  const { pending } = useFormStatus()
  return (
    <Button type="submit" isLoading={pending}>
      {pending ? 'Loading...' : 'Save'}
    </Button>
  )
}

Plus d'interactivité : Server actions + les composants client

La combinaison composant serveur pour récupérer les données et server actions pour les mettre à jour apporte donc de solides avantages. Mais dans la pratique, on a souvent besoin d'un peu plus d’interactivité côté client (validations de formulaire, reset d’erreurs etc…). Les server actions peuvent aussi être invoquées dans un composant client, à l'aide d'une fonction asynchrone.

Ajoutons un toast de succès ou d'erreur à la validation de notre formulaire :

‘use client’
import updateUser from '@/actions/update-user';


const AccountForm = ({ user }: Props) => {
    const { successToast, errorToast } = useCustomToast();
    return (
    <form
        action={async (formData) => {
            // autres opérations de validation du formulaire

            const { error } = await updateUser(formData);
            if (error) {
                errorToast(error.message);
                return;
            }
            successToast('Your account has been updated successfully');
            }
        }
    >

Côté server action, on y intègre la gestion des erreurs avec un try / catch :

export default async function updateUser(formData: FormData): Promise<UpdateUserResult> {
  try {
    // Enregistrement des infos en base
  } catch (err) {
    return {
      error: { message: 'Something went wrong…' },
    }
  }
}

On peut ainsi afficher un message à l’utilisateur en cas de soucis à l’enregistrement.

⚠️ Plus d’interactivité côté client signifie aussi que l’on perd les avantages de l’Amélioration Progressive (Progressive Enhancement). Sans javascript notre formulaire ne sera donc plus très fonctionnel.

Les server actions hors formulaire

Toutes les mutations sur une app ne sont pas forcément liées à un formulaire. Il n’est plus possible dans ce cas d’invoquer notre server action dans l’attribut action. Alors comment faire pour gérer une mutation déclenchée par exemple via un bouton ?

Toujours sur DigestClub, l’encart permettant d’inviter des utilisateurs à rejoindre son équipe affiche un listing des emails déjà invités. Chaque ligne dispose d'un bouton dédié pour supprimer l’invitation : DigestClub Invitations

Deux hooks vont nous intéresser ici :

Invocation avec useTransition

La suppression peut bien entendu être gérée par une serveur action (dans notre cas deleteInvitation). Next précise qu’il faut alors utiliser le hook startTransition pour invoquer la fonction. Le hook renverra par ailleurs l’état de loading de l’action de suppression pour afficher le feedback à l’utilisateur au clic sur le bouton.

import { useTransition } from 'react'

const InvitationList = ({ invitations }: Props) => {
  const { successToast, errorToast } = useCustomToast()
  const [isPending, startTransition] = useTransition()

  const handleDeleteInvitation = async (invitation: TeamInvitation) => {
    startTransition(async () => {
      const { error } = await deleteInvitation(invitation.id)
      if (error) {
        errorToast(error.message)
        return
      } else {
        successToast(message.invitation.delete.success)
      }
    })
  }

  return (
    <div>
      {invitations.map((invitation: TeamInvitation) => (
        <InvitationItem
          key={invitation.id}
          invitation={invitation}
          deleteInvitation={handleDeleteInvitation}
          isLoading={isPending}
        />
      ))}
    </div>
  )
}

export default InvitationList

Une application plus fluide avec useOptimistic

Sur notre exemple, la suppression de l’invitation utilise maintenant une server action mais nécessite cependant un temps incompressible pour réaliser l’action en base. Temps pendant lequel on affiche le loader sur le bouton. Dans ce cas là un autre hook peut s’avérer particulièrement intéressant pour améliorer la réactivité de notre app et l’expérience utilisateur :

useOptimistic est un hook expérimental de React qui permet de mettre directement à jour l’ui en considérant l’issue optimiste de la requête lancée (état de succès). Il prend pour arguments :

  • l’état initial qui sera modifié par la mutation
  • la fonction reducer qui traitera la modification de l’état

En utilisant la fonction renvoyée removeOptiTeamInvitation juste avant notre action et en bouclant sur la liste optimiste d’invitations on a donc une mise à jour immédiate de notre liste.

En cas d’erreur sur le serveur, un revalidatePath remettra de toute façon la liste à jour avec les invitations qui n’ont pas pu être supprimées, et l’erreur sera affichée en toast :

import { experimental_useOptimistic as useOptimistic } from 'react'
import { useTransition } from 'react'

const InvitationList = ({ invitations }: Props) => {
  const [isPending, startTransition] = useTransition()
  const [optiInvitations, removeOptiInvitation] = useOptimistic(
    invitations || [],
    (state: Invitation[], deleteId: string) =>
      [...state].filter((invitation) => invitation?.id !== deleteId)
  )

  const handleDeleteInvitation = async (invitation: TeamInvitation) => {
    startTransition(async () => {
      // mise à jour immédiate de l'ui
      removeOptiInvitation(invitation?.id)
      // mise à jour en base sur le serveur
      const { error } = await deleteInvitation(invitation.id)

      //[...gestion des erreurs]
    })
  }

  return (
    <div>
      {optiInvitations.map((invitation: TeamInvitation) => (
        <InvitationItem
          key={invitation.id}
          invitation={invitation}
          deleteInvitation={handleDeleteInvitation}
        />
      ))}
    </div>
  )
}

export default InvitationList

Et voilà ! Une liste d'invitation qui se met à jour en un clin d'oeil ✨ que demander de plus ?

Conclusion

En conclusion on retiendra que les server actions associés aux nouveaux hooks useFormStatus et useOptimistic promettent une nouvelle petite révolution des échanges client serveur et de la fluidité de l'expérence utilisateur. Mais il faudra encore un peu de patience car on rappelle que pour le moment ces features sont experimentales. En tout cas chez Premier Octet on a hâte de les voir arriver en production ! Et vous ?

18 avenue Parmentier
75011 Paris
+33 1 43 57 39 11
hello@premieroctet.com

Suivez nos aventures

GitHub
Twitter
Flux RSS

Naviguez à vue