Lessons learned from moving to Recoil.js
At Kitemaker, we recently made the leap to Recoil.js for our React state management needs. Before using Recoil, Kitemaker used a simple state management solution built upon useReducer()
. We built Kitemaker to be super fast, responding to every user interaction instantly. However, in organizations with lots of data, we sometimes had a difficult time achieving this due to unnecessary re-renders. Kitemaker has a sync engine under the hood that is constantly syncing data in the background between clients. With useReducer()
this always triggered a top-down re-render and we had to rely on memoization to keep things snappy.
We reached for Recoil to help us minimize re-renders in order to keep Kitemaker fast and responsive regardless of what changes are flowing in via background syncs. We chose it over other competing frameworks like MobX because we liked the explicitness of its API and its similarity to Redux which we were already familiar with. Additionally, the fact that the Meta team designed Recoil for the purpose of building performant UIs with large datasets was a major draw.
While we’ve been very happy with the decision and we have seen the benefits we’d hoped for, it did come with some gotchas.
A little terminology
Let’s get everyone on the same page about Recoil’s terminology first:
- An atom is a piece of state stored by Recoil. In Kitemaker, we have items for things like work items, labels, users, comments and more
- A selector is a derived piece of state. It fetches data from atoms (or other selectors), and transforms them in some way. An example of a selector in Kitemaker would be “fetch all of the comments for this particular work item”
Write fine-grained selectors
In order to make your UI perform well with Recoil, it’s import to understand what actually causes renders. Fortunately, it’s pretty simple - if a selector returns a simple value (string, number, boolean), it triggers a re-render whenever that value changes. If a selector returns an object or an array, Recoil re-renders if the returned value is not referentially equal (i.e. ===
) to the previous value.
What this means in practice is that you want to write selectors that are quite fine-grained. Stick to simple values, avoiding arrays and objects where possible.
Take for example the board view in Kitemaker:
Let’s say we want to render the title of a card. We could write a selector that returns the entire atom, but if we did this, every single time that atom changed, we’d cause a re-render of the component. We don’t care about all the properties of that atom, we just want the title, so we should write a selector like this:
const workItemTitleSelector = selectorFamily({
key: 'WorkItemTitle',
get:
(workItemId: string | undefined | null) =>
({ get }) => {
return get(workItems(workItemId)?.title;
},
});
// only fetch what we need!
function WorkItemCard({ workItemId }: { workItemId: string }) {
const workItemTitle = useRecoilValue(workItemTitleSelector(workItemId));
return (
<div>
{workItemTitle}
</div>
);
}
Now we’re only fetching the title, so only changes to the title will cause a re-render. Win!
Dealing with selectors that return objects and arrays
Before we said you should try to return as specific a value as possible from your selectors. But what if we have no choice but to return an object or an array from your selectors? For example, in Kitemaker, we often return a sorted list of IDs that we’ll then render in some sort of a list or board. Unfortunately, Recoil just doesn’t handle these cases very well out of the box.
We found a solution that allowed us to provide our own equality checks for selectors (or selector families). We have a custom selector family implementation that looks like this:
function equalSelectorFamily<T, P extends SerializableParam>(
options: EqualSelectorFamilyOptions<T, P>
) {
const inner = selectorFamily<T, P>({
key: `${options.key}_inner`,
get: options.get,
});
const priorValues: Map<P, T | undefined> = new Map();
return selectorFamily<T, P>({
...options,
key: options.key,
get:
(param: P) =>
({ get }) => {
const latest = get(inner(param));
const prior = priorValues.get(param);
if (prior != null && options.equals(latest, prior)) {
return prior;
}
priorValues.set(param, latest);
return latest;
},
});
}
The idea is that if two consecutive executions of the selector result in the same value (as defined by the supplied equality operator), the selector just returns the old value, thus preserving referential equality and preventing a re-render.
To use it we do something like this:
const filteredWorkItemsByStatusSelector = equalSelectorFamily({
key: 'FiltereWorkItemsByStatus',
get:
({ spaceId, filterString }: { spaceId: string | undefined; filterString: string }) =>
({ get }) => {
const itemIds: string[] = getFilteredWorkItemsSomehow(get, spaceId, filterString);
return itemIds:
},
equals: _.isEqual,
});
Now if the two arrays returned are equal (as per lodash’s isEqual function in this case), we don’t re-render.
Hopefully Recoil will solve this for us in the future in a proper way 🤞There’s a promising but yet undocumented equality
parameter on selector cachePolicy
that should allow selectors to use value equality instead of referential equality. However, in our initial testing, this still failed to prevent re-renders from selectors that returned objects or arrays.
Leverage transactions
In Kitemaker, we have some complex operations. For example, adding a backlog to a space in Kitemaker creates a new board object, adds various columns to it, and more.
In order to minimize re-renders (and to prevent users from seeing weird intermediate states) we want to apply all of our changes in a single atomic operation. Fortunately, Recoil has an awesome solution for this - transactions.
In the case of Kitemaker, we always use transactions inside of a useRecoilCallback()
function (though there is a useRecoilTransaction_UNSTABLE()
hook as well:
interface BulkItemCreator {
create(items: Array<{ id: string; name: string }>): void;
}
export function useBulkCreateExample() {
return useRecoilCallback(
({ transact_UNSTABLE }) =>
(callback: (creator: BulkItemCreator) => void) => {
transact_UNSTABLE(({ set, get }) => {
callback({
create(items) {
// all of these set() calls happen in one atomic operation
for (const item of items) {
set(itemAtomFamily(item.id), item);
}
// you can use get() here, but only for atoms, not selectors
},
});
});
}
);
}
// Simple example of using a transaction
function SomeComponent() {
const bulkCreate = useBulkCreateExample();
const handleClick = React.useCallback(() => {
bulkCreate((creator) => {
creator.create([
{ id: '123', name: 'Bob' },
{ id: '234', name: 'Susan' },
]);
});
}, []);
return <button onClick={handleClick}>Create</button>;
}
These transactions are considered an unstable feature (thus the naming) but in our experience they work great. There are some gotchas however - the main one being that you may not access any selectors inside of a transaction. This is because selectors are not updated as the individual operations of a transaction are applied to the recoil state, so the data in the selectors would be stale. This hasn’t been a big problem for us as the data fetching needs of this transaction code tends to be very simple. Also there’s a performance overhead with creating the snapshot inside of a transaction, so keep this in mind.
Watch your atom count
As the number of atoms in Recoil increases, there’s some performance impact. There is a lot of bookkeeping for dependency checking, etc. inside of Recoil that does not scale linearly. Additionally, whenever you need to grab a snapshot, it gets slower based on how large your set of atoms is. As a result, you need to think a bit about how you bucket your data into atoms. Does every single object need to be a separate atom? Or maybe if you always access a list of items together as a unit, you can shove the entire list into an atom. Then you can still write nice specific selectors on top (possibly using the equality tricks above) to fetch just the data you need to minimize re-renders.
Of course selectors are not free and in some cases changes to data may cause selectors to be frequently reevaluated. That means it’s a bit of a balancing act between specific selectors and managing your atom count. A rule of thumb is to group up atoms that are generally rendered together and which change relatively infrequently.
Wrapping it up
To summarize, using Recoil we were able to eliminate unnecessary renders in React. Instead of top-down, full screen re-renders with a bunch of memoization, we now have a very minimal number of re-renders whenever our data changes. However, Recoil isn’t magic. It really pays to take the time to understand what things causes re-renders and other performance issues so you can avoid those landmines in your implementation.
Want to learn more about Recoil or how we built Kitemaker? Always happy to chat on Twitter or email.