Inside JOIN

JOIN Stories makes the transition from Redux to Recoil: how and why?

Mar 15, 2023
author

Edouard Short

Software engineer

Anyone who has taken on the challenge of developing an authoring tool knows that data management is one of the biggest technical challenges to face.  

The React ecosystem has many data management libraries, even more if you take into account the derivatives of them. At JOIN Stories, we wanted to rethink our data management to take a more atomic approach, which motivated the migration from Redux to Recoil

For those who are curious and for those who will get knowledge for their own tools, we tell you our story.

Summary of the article

The context

JOIN Stories, an innovative tool that allows you to create, distribute and analyze immersive and impactful Web Stories. How exactly do we ensure that our products live up to expectations? 

The JOIN Stories interface allows you to create content in Web Story format in an intuitive and dynamic way like Canva or Figma. When editing a story, we manipulate it in the format of a complex JS object, containing N pages and each of these pages containing N elements. 

Experience has taught us that, in practice, it can be difficult to manipulate and display such a complex object without creating abusive re-renders, or without memorizing too large objects. How to overcome these problems? 

This is the type of problem we were experiencing with our Redux-based data management stack.

Here you can see a schematic of our current Redux data structure:


{
  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 ...
    ]
  }
}

The path from Redux to Recoil

First step: review our data structuring

It is in this context that we decided to completely change the way we handle data in our App. In order to limit the problems we decided to completely separate our data into a multitude of elements rather than manipulating everything in one big object. 

Thus, the Story (the main object), would no longer contain pages, but a list of page IDs. The content of the pages would be stored in a separate dictionary. In the same way, the pages would no longer contain elements, but a list of element IDs.

The separation of the data into different structures allows the different components to subscribe only to the necessary data, and thus to limit unwanted re-renders.

Here is a schematic of our new data structure on 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
  }
}

Step Two: Review our library selection

It is possible to implement this architecture with different libraries, but it seemed obvious to us to use Recoil. Why? Recoil puts forward this atomic vision of data management.

Moreover, we had already used this library previously to solve performance problems in a more restricted context. We were therefore confident in its ability to solve our problems.

We start the implementation

First, the Atoms

To implement this architecture on Recoil, we had to create :

  • 1 object (Atom)

This first atom contains the information of the Story.

  • 2 dictionaries (AtomFamily)

The first dictionary contains the information of the pages (remember that there are N pages in a story).

The second dictionary contains elements (remember that there are N elements per page).

Story Atom

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

AtomFamily page

 
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
    },
});

In our case, we decided to divide our application into three atoms for a Story. This allows us to work with data that is small enough to avoid subscribing to unnecessary data, but complete enough to be relevant to the use. 

We then validated this data structure by performing initial tests to verify that the number of re-renders was not too large.

Then, the Selectors

Now that we have a data separated in several structures, access to the complete and aggregated data can become more complicated.

Knowing this, it was important for us to set up different selectors that would allow us to retrieve a less raw data, which would be easier to manipulate in certain situations.

With these selectors you can subscribe only specific components to this data, and thus limit the possibility of unwanted re-renders.

We have therefore implemented the following selectors to be able to directly manipulate data from hydrated Pages or Story complements:

PageSelector

This selectorFamily allows to set and get a page containing directly the elements.

 
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

This selector allows to get and set the complete story. By complete, we mean that it contains the information of the story with the information of the pages and the elements in these pages. This allows us not to have to change the data structure in the database, since it is very suitable for no-sql.

This allows us, among other things, to initialize the store by giving it the complete story object directly.


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,
});

Display of the data

Then, we create 3 components to render the story, the pages and the elements. Each one subscribing only to its own data.

The use of react memo allows to avoid that an element renders if its parent is updated but that it does not impact it. For example, if you add an element to a page, you don't need to re-render all the other elements on that 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

In reality, the final structure is much more complex, and it can be difficult to make the transition from an existing codebase.

At JOIN Stories, the transition was complex, but we have seen significant performance improvements. We saw the time of the processors tasks divided by 2 on a set of actions.

As a warning to those who would like to follow our example, we would like to remind you that the performance improvement is also due to the change in the structuring of our data, and not to the simple migration to Recoil. Recoil is just the tool that we felt was best suited to perform this migration.

Such a restructuring could also be done via Redux, which by the way we have not completely abandoned, since it is still used to manage user data, an object that is less complex and less often mutated.

→ At the risk of repeating ourselves, we add that Recoil does not necessarily replace Reduxwe still use Redux for our global application store, as the atomic approach to managing our elements seemed more suitable, and the response time of Recoil vs Redux had a real added value.

Share this post
linkedintwitterfacebook

Stay up to date.

Sign up for the JOIN Stories newsletter to get all our latest resources.

Boost your audience's engagement.

Discover JOIN Stories and integrate Web Stories on your communication media.