Utiliser l'Optimistic UI avec React et Apollo GraphQL

Améliorez l'expérience utilisateur et la réactivité de vos applications React avec un peu d'optimisme !

English version : How to use Optimistic UI in React and Apollo GraphQL

Objectifs

  • Découvrir le principe de l'optimistic UI
  • L'utiliser avec React et GraphQL pour augmenter l'expérience utilisateur

Contexte

Le temps de réponse entre le backend et le frontend est un des facteurs limitants l'expérience utilisateur. C'est encore plus vrai lorsque l'application ou le site web est destiné aux mobiles qui peuvent avoir une connexion dégradée.

Dès qu'un utilisateur effectue une action d'envoi de données au serveur, ce qu'on appelle une mutation, l'application cliente attend le retour du serveur pour mettre à jour l'interface. Si le temps de réponse du serveur est important, l'expérience utilisateur est mauvaise.

anim1

Un peu de théorie

L'application gère une liste de personnages, on souhaite pouvoir ajouter des personnages. Les personnages possède un ID unique, et un Username. Le réseau est très lent.

L'utilisateur veut ajouter un personnage, il doit pour cela saisir son Username et cliquer sur un bouton d'enregistrement. Une mutation est envoyé au serveur qui va créer le personnage, générer son ID et renvoyer une liste de personnages à jour.

Voici un schema des actions, lorsque tout se passe bien :

schema_optimistic1

Il pourrait éventuellement y avoir un souci côté serveur, et la création du nouveau personnage peut ne pas se dérouler comme prévu. Dans ce cas, le schéma pourrait ressembler à cela :

schema_optimistic2

L'optimistic UI est simplement le fait de se placer dans le cas ou tout se déroule comme prévu : le client ignore ce qui se passe côté serveur dans un premier temps, il part du principe que tout va bien se passer. Dans un second temps il récupère les informations du serveur, met à jour les propriétés qui ont été générés par le serveur (ici l'ID), et en cas d'erreur il revient à l'état initial.

schema_optimistic3

Pour l'utilisateur, son action est ressentie comme étant instantanée.

Lancement du projet

Pour mettre en pratique notre exemple, il nous faut une application cliente (ici en React), un serveur (Node.js), les deux reliés par une API (GraphQL). Côté client il faudra une gestion du cache ou de l'état (state), Apollo GraphQL le fait parfaitement.

On utilise create-react-app pour bootstraper l'application cliente avec une configuration de base.

Le serveur est un serveur Apollo Graphql classique, avec un middleware express qui va simuler un temps de réponse de 1.5 secondes :

// server.js - Add some big lag
app.use((req, res, next) => {
  setTimeout(() => next(), 1500);
});

L'application cliente

Notre component React affiche une liste de personnages, on récupère la liste via une requête GraphQL. On passe ensuite la liste au compoonent qui se chargera de l'afficher.

// UsersWithData
const queryRequest = gql`
  query {
    users {
      id
      username
    }
  }
`;

function UsersWithData() {
  // Thanks to Apollo Hooks
  const { data, loading } = useQuery(queryRequest);

  // Inform user data is coming
  if (loading)
    return (
      <main>
        <div>loading...</div>
      </main>
    );

  return <Users users={data.users} />;
}

Le component principal affiche la liste et un champ qui permet de saisir le nom du nouveau personnage :

// Users
function Users({ users }) {
  const [username, setUsername] = useState("");

  const changeUsername = (e) => {
    setUsername(e.target.value);
  };

  return (
    <main>
      <div>
        Username :
        <input type={"text"} value={username} onChange={changeUsername} />
        <button onClick={handleCreateUser}>add</button>
        {users.map((user) => (
          <div key={user.id}>
            <div>{user.username}</div>
            <button>X</button>
          </div>
        ))}
      </div>
    </main>
  );
}

On ajoute la mutation GraphQL grâce au hook useMutation fourni par Apollo :

// Users
const mutationRequest = gql`
  mutation($username: String!) {
    createUser(username: $username) {
      id
      username
    }
  }
`;

function Users({ users }) {
  const [username, setUsername] = useState("");
  const [createUser] = useMutation(mutationRequest);

  const handleCreateUser = () => {
    createUser({
      variables: { username },
      refetchQueries: [{ query: queryRequest }],
    }).catch((err) => err);
  };

  const changeUsername = (e) => {
    setUsername(e.target.value);
  };

  return (
    <main>
      <div>
        Username :
        <input type={"text"} value={username} onChange={changeUsername} />
        <button onClick={handleCreateUser}>add</button>
        {users.map((user) => (
          <div key={user.id}>
            <div>{user.username}</div>
            <button>X</button>
          </div>
        ))}
      </div>
    </main>
  );
}

L'application est fonctionnelle et permet d'ajouter un personnage et de récupérer la liste à jour provenant du serveur. L'opération de refetch de la liste prend du temps et laisse une sensation désagréable à l'utilisateur !

Soyons optimiste !

Pour ajouter de l'optimistic UI à notre application, il nous suffit de changer la stratégie de refetch des données lors de la mutation. Dans la mutation précédente, une query était passée en paramètre via la propriété "refetchQueries", qui indiquait à GraphQL de ré-exécuter cette query après la mutation et mettre à jour le cache.

Pour utiliser l'optimistic UI d'Apollo, on a accès à deux propriétés : "optimisticResponse" et "update".

La première propriété, "optimisticResponse" gère l'entité que l'on créé, ici le personnage, la propriété "update" définit comment mettre à jour notre cache local avec cette nouvelle entité. Cette mise à jour s'effectuera sans que l'utilisateur le perçoive, dès réception de la réponse du serveur.

const handleCreateUser = () => {
  createUser({
    variables: { username },

    optimisticResponse: {
      __typename: "Mutation",
      // use the uuid library to have a unique ID
      createUser: { __typename: "User", id: 123456, username },
    },

    update: (proxy, { data: { createUser } }) => {
      // Get the data from GraphQL cache
      const data = proxy.readQuery({ query: queryRequest });
      // Update the cache with the query
      proxy.writeQuery({
        query: queryRequest,
        data: { ...data, users: [...data.users, createUser] },
      });
    },
  }).catch((err) => err);
  setUsername("");
};

Voici les deux components utilisés l'un avec Optimistic UI, l'autre sans :

anim2

Vous pouvez retrouver toutes les sources ici.