Inside JOIN

JOIN Stories fait la transition de Redux à Recoil : comment et pourquoi ?

Mar 15, 2023
author

Edouard Short

Ingénieur logiciel

Tous ceux qui ont relevé le défi de développer un outil de création savent que la gestion de la donnée est un des plus gros défis techniques à affronter.  

L’écosystème React compte des nombreuses bibliothèques de gestions de données, encore plus si on prend en compte les dérivés de celles-ci. Chez JOIN Stories, nous avons voulu revoir notre gestion des données pour prendre une approche plus atomique, ce qui a motivé la migration de Redux à Recoil

Pour les curieux et pour ceux qui en tireront des connaissances pour leurs propres outils, on vous raconte notre histoire.

Sommaire de l’article

Le contexte

JOIN Stories, un outil innovant qui permet de créer, diffuser et analyser de Web Stories immersives et impactantes. Comment, au juste, faisons-nous pour garantir un produit à l’hauteur des attentes ? 

L’interface de JOIN Stories permet de créer des contenus en format Web Story de manière intuitive et dynamique à l’instar de Canva ou Figma. Lors de l’édition d’une story, nous la manipulons au format d’un objet JS complexe, contenant N pages et chacune de ces pages contenant N éléments. 

L'expérience nous a appris que, dans la pratique, il peut être difficile de manipuler et d’afficher un objet aussi complexe sans créer de re-render abusif, ou sans memoriser des objets trop conséquents. Comment faire pour surmonter ces problématiques ? 

C’est le type de problèmes que nous rencontrions avec notre stack de gestion de données basée sur Redux.

Vous pouvez voir ici une schématisation de notre structure de données Redux actuelle :


{
  story: {
  // Story informations
    id: 'my-story-id',
    name: 'my-story-name',
    creationDate: '2023-02-28T09:04:32.609Z',
    // ...
    pages: [
      {
      // Page informations
        id: 'my-page-id',
        templateId: 'my-template-id',
        // ...
        elements: [
          {
          // Element informations
            id: 'my-element-id',
            type: 'text',
            content: 'I am an element !',
            position: {
              x: 10,
              y: 10
            }
            // ...
          }
          // Other elements ...
        ]
      }
      // Other pages ...
    ]
  }
}

Le chemin de Redux à Recoil

Première étape : revoir notre structuration des données

C’est dans ce contexte que nous avons décidé de changer complètement notre façon de gérer les données dans notre App. Afin de limiter les problèmes nous avons décidé de complètement séparer notre donnée en une multitude d’éléments plutôt que de tout manipuler en un seul gros objet. 

Ainsi, la Story (l’objet principal), ne contiendrait plus des pages, mais une liste d’IDs de pages. Le contenu des pages serait stocké dans un dictionnaire à part. De la même manière, les pages ne contiendraient plus d’élément, mais une liste d’ID d’éléments.

La séparation de la donnée en différentes structures permet aux différents composants de ne s’abonner qu’aux données nécessaires, et donc de limiter les re-render non désirés.

Voici une schématisation de notre nouvelle structure de données sur Recoil :


{
  story: {
    // Story informations
    id: 'my-story-id',
    name: 'my-story-name',
    creationDate: '2023-02-28T09:04:32.609Z',
    // ...
    pageIds: [
      'my-page-id',
      // Other pageIds ...
    ]
  },
  pages: { 
    'my-page-id' : {
      // Page informations
      id: 'my-page-id',
      templateId: 'my-template-id',
      // ...
      elementIds: [
        'my-element-id',
        // Other elementIds ...
      ]
    }
    // Other pages ...
  },
  elements: {
    'my-element-id': {
      // Element informations
      id: 'my-element-id',
      type: 'text',
      content: 'I am an element !',
      position: {
        x: 10,
        y: 10
      }
      // ...
    }
    // Other elements
  }
}

Deuxième étape : revoir notre choix de bibliothèque

Il est possible d’implémenter cette architecture avec différentes librairies, mais cela nous semblait évident d’utiliser Recoil. Pourquoi ? Recoil met en avant cette vision atomique de la gestion de données.

De plus, nous avions déjà utilisé cette librairie précédemment pour résoudre des problématiques de performance dans un contexte plus restreint. Nous étions donc confiants en sa capacité à pallier nos problèmes.

On démarre la mise en place

D’abord, les Atomes

Pour mettre en place cette architecture sur Recoil, nous avons donc dû créer :

  • 1 objet (Atom)

Ce premier atome contient les informations de la Story.

  • 2 dictionnaires (AtomFamily)

Le premier dictionnaire contient les informations des pages (on rappelle qu’il y a N pages dans une story).

Le second dictionnaire contient des éléments (on rappelle qu’il y a N éléments par page).

Story Atom

 
export const storyInformationAtom = atom({
    key: 'storyInformation',
    default: {
        pageIds: [],
        // ... other story related keys
    },
});

Page AtomFamily

 
export const pageInformationAtom = atomFamily({
    key: 'pageInformation',
    default: (id) => {
        id,
        elementIds: [],
        // ... other page related keys
    },
});

Element AtomFamily

 
export const pageElementAtom = atomFamily({
    key: 'pageElement',
    default: (id) => {
    id,
      // ... other element related keys
    },
});

Dans notre cas, nous avons décidé de diviser notre application en trois atomes pour une Story. Cela nous permet de travailler avec des données suffisamment restreintes pour éviter de s'abonner à des données inutiles, mais suffisamment complètes pour être pertinentes à l'utilisation. 

Nous avons ensuite validé cette structure de données en effectuant des tests initiaux pour vérifier que le nombre de re-render n'étaient pas trop importants.

Ensuite, les Sélecteurs

Maintenant que l’on a une donnée séparée en plusieurs structures, l’accès à la donnée complète et agrégée peut se compliquer.

Sachant cela, il était important pour nous de mettre en place différents sélecteurs qui nous permettraient de récupérer une donnée moins brute, qui serait plus simple à manipuler dans certaines situations.

Avec ces sélecteurs on peut abonner seulement certains composants spécifiques à cette donnée, et donc limiter la possibilité de re-render non désirés.

Nous avons donc mis en place les sélecteurs suivants pour pouvoir manipuler directement des données de Pages ou de Story complémentent hydratés :

PageSelector

Ce selectorFamily permet de set et de get une page contenant directement les éléments.

 
const populatedPageGetter =
    (pageId) =>
    ({ get }) => {
        const { elementIds, ...page } = get(pageInformationAtom(pageId));
        const elements = elementIds.map((id) => {
            return get(pageElementAtom(id));
        });
        return { ...page, elements };
    };

const populatedPageSetter =
    (pageId) =>
    ({ get, set }, page) => {
        const { elements, ...pageInformation } = page;
        const elementIds = elements.map(({ id }) => id);

        set(pageInformationAtom(pageId), {
            ...pageInformation,
            elementIds: newElementIds,
        });
        elements.forEach((element) => {{
		        set(pageElementAtom(element.id), element);
        });
    };

export const populatedPageSelector = selectorFamily({
    key: 'populatedPage',
    get: populatedPageGetter,
    set: populatedPageSetter,
});

StorySelector

Ce selector permet de get et set la story complète. Par complète, nous entendons qui contient les informations de la Story avec en plus les informations des pages et des éléments dans ces pages. Cela nous permet de ne pas avoir à changer la structure de données en base de données, puisque celle-ci est très adaptée à du no-sql.

Cela nous permet entre autres d’initialiser le store en lui donnant directement l’objet de la story complet.


const populatedStoryGetter = ({ get }) => {
    const { pageIds, ...storyInfo } = get(storyInformationAtom);
    const pages = pageIds.map((pageId) => get(populatedPageSelector(pageId));
    return { ...storyInfo, pages } as StoryType;
};

const populatedStorySetter = ({ get, set }, story) => {
    const { pages, ...restStory } = story;

    const pageIds = pages.map((page) => page.id);

    set(storyInformationAtom, { ...restStory, needInitialization, pageIds });
    pages.forEach((page) => {
        set(populatedPageSelector(page.id), page);
    });
};

export const populatedStorySelector = selector({
    key: 'populatedStory',
    get: populatedStoryGetter,
    set: populatedStorySetter,
});

Affichage de la donnée

Ensuite, on crée 3 composants pour rendre la story, les pages et les éléments. Chacun s’abonnant uniquement à sa propre donnée.

L’utilisation de react memo permet d’éviter qu’un élément se rende si son parent est mis à jour mais que ça ne l’impact pas. Par exemple, si on ajoute un élément à une page, il n’est pas nécessaire de re-render tous les autres éléments de cette page.

 
const populatedStoryGetter = ({ get }) => {
    const { pageIds, ...storyInfo } = get(storyInformationAtom);
    const pages = pageIds.map((pageId) => get(populatedPageSelector(pageId));
    return { ...storyInfo, pages } as StoryType;
};

const populatedStorySetter = ({ get, set }, story) => {
    const { pages, ...restStory } = story;

    const pageIds = pages.map((page) => page.id);

    set(storyInformationAtom, { ...restStory, needInitialization, pageIds });
    pages.forEach((page) => {
        set(populatedPageSelector(page.id), page);
    });
};

export const populatedStorySelector = selector({
    key: 'populatedStory',
    get: populatedStoryGetter,
    set: populatedStorySetter,
});

Conclusion

Dans la réalité, la structure finale est bien plus complexe, et il peut être difficile de faire la transition depuis une codebase existante.

Chez JOIN Stories, la transition fut complexe, mais on a pu observer de fortes améliorations de performance. On a vu le temps des tâches processeurs divisées par 2 sur un ensemble d’actions.

En guise d’avertissement à ceux qui voudraient suivre notre exemple, nous tenons à rappeler que l’amélioration de ces performances est aussi due au changement de la structuration de notre donnée, et non pas à la simple migration à Recoil. Recoil est juste l’outil qui nous semblait le plus adapté pour effectuer cette migration.

Une telle restructuration pourrait également être faite via Redux, que d’ailleurs nous n’avons pas complètement abandonné, puisqu’il nous sert toujours à gérer les données utilisateurs, un objet moins complexe et moins souvent muté.

→ Au risque de se répéter, on ajoute que Recoil ne remplace pas nécessairement Redux, nous utilisons toujours Redux pour notre store global d’application, car l’approche atomique pour gérer nos éléments semblait plus adaptée, et le temps de réponse de Recoil vs Redux avait une vraie valeur ajoutée.

Partagez ce post
linkedintwitterfacebook

Restez à la page.

Inscrivez-vous à la newsletter JOIN Stories pour profiter de toutes nos dernières ressources.

Boostez l’engagement de votre audience.

Découvrez JOIN Stories et intégrez des Web Stories sur vos supports de communication.