codelynx.dev
🇫🇷🇬🇧

Retour 07/10/2024

Comment fonctionnent les server-actions en NextJS ?

Écris par Melvyn Malherbe le 07/10/2024


Les Server Actions en NextJS sont devenues incontournables mais beaucoup de personnes trouvent qu'elles sont "magiques" alors que non. Derrière les server-actions, il y a du code, de la logique et ensemble nous allons venir démystifier les Server-Action.

C'est quoi une server-action (en bref) ?

Pour rappel, les server-actions interviennent quand tu utilises "use server" dans un fichier, par exemple une fonction qui permet de modifier le username d'un utilisateur.

JS
"use server";

export const updateUsernameAction = async (id, username) => {
  const user = await prisma.user.update({ 
    where: {
      id,
    },
    data: {
      username,
    },
  });
  return user;
};

Cette méthode peut ensuite être appelée dans ton front-end comme n'importe quelle méthode :

JSX
"use client";

const App = () => {
  return (
    <button onClick={() => updateUsernameAction(1, 'Melvynx')}>
      Update Username
    </button>
  )
}

Mais par quelle magie NextJS arrive-t-il à définir d'où vient cette méthode updateUsernameAction et surtout quelle méthode appeler ?

Pour en savoir plus, je t'invite à jeter un œil à mon article complet sur le sujet.

Comment NextJS trouve les server-actions ?

Imaginons une server-action qui s'appellerait getServerActionBackendData, NextJS va regarder toutes les méthodes que notre page appelle et va les inclure dans le bundle JavaScript.

En regardant le fichier page.js qu'on reçoit quand on charge la page, on peut voir ceci :

Le fichier page.js

On peut voir que dans le bundle JavaScript, on voit clairement la méthode getServerActionBackendData qui est ajoutée. Celle-ci est définie comme ça :

JS
var getServerActionBackendData = () => {
  private_next_rsc_action_client_wrapper__WEBPACK_IMPORTED_MODULE_1__.createServerReference("0008bfae0df0222acb8b0c3443136c4c");
};

Cette méthode va être injectée dans notre page pour que quand notre client l'appelle, elle soit clairement définie.

On peut voir qu'elle appelle une méthode, createServerReference qui est définie dans le fichier [react-server-dom-turbopack-client.browser.development.js] :

JS
exports.createServerReference = function (id, callServer) {
  function action() {
    var args = Array.prototype.slice.call(arguments);
    return callServer(id, args);
  }
  registerServerReference(action, { id: id, bound: null });
  return action;
};

On peut voir ici que ce que la méthode va faire, c'est prendre un id puis prendre une méthode callServer, qu'on va voir ensuite. Ce que fait la méthode action c'est prendre tous les arguments avec lesquels on appelle cette méthode et venir appeler callServer avec tous ces arguments et ce fameux id.

Les méthodes avec la syntaxe function peuvent accéder à une variable arguments qui contient tous les arguments passés à la fonction.

Ensuite la méthode callServer est appelée avec l'id unique de notre action, et nos différents arguments. Et justement... que fait callServer :

JS
// Le code est rendu plus court pour la lisibilité de l'article
const serverActionDispatcher: ServerActionDispatcher = useCallback(
  (actionPayload) => {
    startTransition(() => {
      dispatch({
        ...actionPayload,
        type: ACTION_SERVER_ACTION,
      })
    })
  },
  [dispatch]
)
const globalServerActionDispatcher = serverActionDispatcher;

export async function callServer(actionId: string, actionArgs: any[]) {
  const actionDispatcher = globalServerActionDispatcher

  if (!actionDispatcher) {
    throw new Error('Invariant: missing action dispatcher.')
  }

  return new Promise((resolve, reject) => {
    actionDispatcher({
      actionId,
      actionArgs,
      resolve,
      reject,
    })
  })
}

callServer vient venir retourner une promise qui appelle un "dispatcher" avec le type ACTION_SERVER_ACTION et les arguments passés à la fonction.

Ce qui vient appeler un client reducer :

JS
/**
 * Reducer that handles the app-router state updates.
 */
function clientReducer(
  state: ReadonlyReducerState,
  action: ReducerActions
): ReducerState {
  switch (action.type) {
    // Autre cas...
    case ACTION_SERVER_ACTION: {
      return serverActionReducer(state, action)
    }
    // This case should never be hit as dispatch is strongly typed.
    default:
      throw new Error('Unknown action')
  }
}

Qui vient finalement appeler une méthode serverActionReducer en passant en premier paramètre le state et en deuxième l'action. Et justement... que fait serverActionReducer :

JS
export function serverActionReducer(
  state: ReadonlyReducerState,
  action: ServerActionAction
): ReducerState {
  const { resolve, reject } = action
  const mutable: ServerActionMutable = {}
  const href = state.canonicalUrl

  let currentTree = state.tree

  const nextUrl =
    state.nextUrl && hasInterceptionRouteInCurrentTree(state.tree)
      ? state.nextUrl
      : null

  return fetchServerAction(state, nextUrl, action).then(
    async ({
      actionResult,
      actionFlightData: flightData,
      redirectLocation,
      redirectType,
      isPrerender,
      revalidatedParts,
    }) => {
      // Va venir gérer le résultat
      // Si la server-action est une redirection = on redirige
      // Si la server-action vient revalidate des données = on modifie les données
      // Si la server-action retourne des données = on les retourne
    },
    (e: any) => {
      // When the server action is rejected we don't update the state and instead call the reject handler of the promise.
      reject(e)

      return state
    }
  )
}

Cette méthode s'occupe uniquement d'appeler une autre méthode fetchServerAction et de récupérer son résultat. Il faut savoir qu'une action peut faire plusieurs choses :

  1. revalidate des données avec revalidatePath
  2. redirect vers une autre page
  3. return des données

Maintenant, que fait fetchServerAction ?

JS
async function fetchServerAction(
  state: ReadonlyReducerState,
  nextUrl: ReadonlyReducerState['nextUrl'],
  { actionId, actionArgs }: ServerActionAction
): Promise<FetchServerActionResult> {
  const body = await encodeReply(actionArgs)

  const res = await fetch('', {
    method: 'POST',
    headers: {
      Accept: RSC_CONTENT_TYPE_HEADER,
      [ACTION_HEADER]: actionId,
      [NEXT_ROUTER_STATE_TREE_HEADER]: encodeURIComponent(
        JSON.stringify(state.tree)
      ),
      ...(process.env.NEXT_DEPLOYMENT_ID
        ? {
            'x-deployment-id': process.env.NEXT_DEPLOYMENT_ID,
          }
        : {}),
      ...(nextUrl
        ? {
            [NEXT_URL]: nextUrl,
          }
        : {}),
    },
    body,
  })

  const redirectHeader = res.headers.get('x-action-redirect')
  // ... gère la redirection ...

  const isPrerender = !!res.headers.get(NEXT_IS_PRERENDER_HEADER)
  let revalidatedParts: FetchServerActionResult['revalidatedParts']
  // ... gère la revalidation ...

  const contentType = res.headers.get('content-type')

  // Récupère le contenu retourné par la fonction
  if (contentType?.startsWith(RSC_CONTENT_TYPE_HEADER)) {
    const response: ActionFlightResponse = await createFromFetch(
      Promise.resolve(res),
      { callServer, findSourceMapURL }
    )

    return {
      actionResult: response.a,
      actionFlightData: normalizeFlightData(response.f),
      redirectLocation,
      redirectType,
      revalidatedParts,
      isPrerender,
    }
  }

  return {
    redirectLocation,
    redirectType,
    revalidatedParts,
    isPrerender,
  }
}

Cette méthode va venir faire un fetch API à l'URL qui est actuellement affichée. Il vient rajouter 2 données très importantes :

  1. [ACTION_HEADER]: actionId, = ceci permet à NextJS de savoir quelle méthode backend appeler
  2. Accept: RSC_CONTENT_TYPE_HEADER, = ceci permet de dire au backend qu'on attend une réponse en format "server-action"

Ensuite, on ajoute le body et d'autres données.

Puis on prend le résultat retourné du fetch pour reconstruire un gros objet :

JS
return {
  actionResult: response.a, // Le résultat retourné de la server-action
  redirectLocation, // La redirection si elle existe
  redirectType, 
  revalidatedParts, // La revalidation si elle existe
  isPrerender,
}

Et c'est comme ça qu'après, la méthode serverActionReducer vient récupérer ce gros objet pour venir faire la bonne action en fonction des données disponibles.

Tout ceci, c'est ce qui se passe sous le capot quand tu appelles une server-action.

Comment le backend de NextJS gère les server-actions ?

Sans rentrer dans la technique cette fois, car elle est encore plus compliquée mais le backend de NextJS va venir définir si c'est une server-action via le header [ACTION_HEADER]: actionId ou pas.

Si c'est une server-action, elle va venir rechercher la bonne action grâce à l'identifiant actionId et va ensuite appeler la méthode.

Ce qu'on peut voir c'est que cet actionId est un vrai router :

Juste qu'au lieu d'avoir des URL comme /api/users/1 on vient avoir des méthodes mais en backend, on fait à peu près la même chose.

Conclusion

Je voulais te montrer que les server-actions ne sont pas de la magie et que finalement, comme tout dans la tech (c'est important de se le dire) tout est toujours du code.

Tous ces frameworks nous permettent juste de simplifier la vie des développeurs en offrant une meilleure DX mais derrière, c'est toujours les mêmes règles qui régissent le monde du JavaScript.

Si tu souhaites vriament maîtriser NextJS je t'invite à t'inscrire à mon cours gratuit ici :

Le meilleur moyen d'apprendre NextJS !

Rejoins par développeurs, cette formation reçoit une note de 4.7 / 5 🚀

Reçois la formation gratuitement dans ta boîte mail :

NextReact

Cours NextJS gratuit

Accède à des exercices, des vidéos et bien plus sur NextJS dans la formation "NextReact" 👇