Inside JOIN

06.10.2025

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

Anyone who has taken up the challenge of developing a creative tool knows that data management is one of the biggest technical challenges to face.

The React ecosystem has numerous data management libraries, even more so if we take into account the derivatives of these libraries. Chez JOIN Stories, we wanted to review our data management to take a more atomic approach, which motivated the migration from Redux to Recoil.

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

The context

JOIN Stories, an innovative tool that allows you to create, broadcast and analyze Immersive and impactful Web Stories. Exactly what do we do to ensure a product that meets 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 remembering too large objects. What can we do to overcome these problems?

These are the types of issues we were having 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 structure

It is in this context that we decided to completely change the way we manage 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 into a single large object.

So, 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, pages would no longer contain items, but a list of item IDs.

The separation of the data into different structures allows the different components to subscribe only to the necessary data, and therefore 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
}
}

Second step: review our library choice

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

In addition, we had already used this library previously to solve performance problems in a more restricted context. So we were confident in its ability to alleviate our problems.

We are starting to set up

First, the Atoms

To set up this architecture on Recoil, we therefore had to create:

  • 1 object (Atom)

This first atom contains the information from 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
},
});

AtomFamily element

 
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 useless data, but comprehensive enough to be relevant to use.

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

Then the Selectors

Now that we have data separated into several structures, accessing complete and aggregated data can be complicated.

Knowing this, it was important for us to set up various selectors that would allow us to recover less raw data, which would be easier to handle in certain situations.

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

We have therefore set up the following selectors to be able to directly manipulate hydrated Page or Story data directly:

PageSelector

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

 
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 Get and Set the full story. By complete, we mean who contains the information in the Story in addition to the information about the pages and the items in those pages. This allows us not to have to change the data structure in the database, since it is very suitable for no-sql.

Among other things, this allows us to initialize the store by directly giving it the object of the complete story.


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

Data display

Then, we create 3 components to make the story, pages, and items. Each subscribing only to their own data.

The use of react memo makes it possible to avoid an element from being rendered if its parent is updated but that does not impact it. For example, if you add an element to a page, there is no 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 were able to observe strong performance improvements. We have seen the time of processor tasks divided by 2 over a set of actions.

As a warning to those who would like to follow our example, we would like to remind you that the improvement in these performances is also due to the change in the structure of our data, and not simply migrating to Recoil. Recoil is just the tool that seemed to us to be the most suitable for carrying out this migration.

Such a restructuring could also be done via Redux, which in fact 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 being repeated, we add that Recoil does not necessarily replace Redux, we are still using Redux for our global application store, because the atomic approach to managing our elements seemed more appropriate, and the response time of Recoil vs Redux had real added value.

{{cta}}

Discover more content on the same subject.

Inside JOIN

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

Read the article

What if we showed you JOIN,

on your own site?