I’m always eager to explore learning paths (you can read more about it in my Choose what NOT to study and focus on one thing at a time article), but I was still missing re-implementing APIs. An article by Kent C. Dodds inspired me, and here we are.
We use Recoil at work; it’s a core element of WorkWave RouteManager's next architecture. Recoil has good ease of use, and it removes every distinction between local and global state management. It’s not perfect yet but relatively stable.
Please note: This article remained unpublished for eight months. I should have rewritten it because I realized that code and design decisions are more understandable through visuals, leaving the code at the end. But since done is better than perfect, I decided to publish the article as is. As a reminder for myself, I think Maty Perry’s “Layout projection: A method for animating browser layouts at 60fps” is a perfect example of how to write a technical article.
Requirements
The goal was re-implementing the atom and the selector APIs, only the sync version. It means
implementing the
atom
API to create new atomsimplementing the
selector
API to create new selectors that depend on atoms and other selectorsimplementing the
useRecoilValue
API to get an atom/selector value and subscribing (aka getting re-rendered) to their updateimplementing the
useRecoilState
API to get alluseRecoilValue
features plus setting the atom/selectorimplementing the
RecoilRoot
API to avoid sharing the state between different component trees (I need it just for the sake of the tests)
Before diving into the code, what we need is:
storing the values of the Atoms: the atoms themselves are just plain objects, the Recoil store must keep their current state
updating the subscribed components when an Atom updates and forcing them to re-render
exposing API through React hooks
differentiate every store with an id since every RecoilRoot is independent
keep a RecoilRoot’s ID private, we don’t want to expose internal implementation details
Possible solutions:
stored values (#1) will be Plain Objects
for #2 we need to register a callback for every subscriber
the only way we can force a component to re-render (#3) is to keep an internal state and update it. How other state libraries do it? Looking at its internals, Recoil does it through
useState
const [_, setValue] = useState({});
forceUpdate = () => setValue({});
while Redux, internally, does it through useReducer
const [, forceRender] = useReducer(s => s + 1, 0)
I take #4 for granted nowadays 😉
#5 requires us to use a React Context, owned by the RecoilRoot component
We’ll manage #6 with some higher-order functions that wrap the core ones (later, I’ll explain how to do that)
What about Selectors?
Selectors are stateless. Their
get
are pure functions that derive their values from other Atoms and SelectorsSelectors must update when one of the Atoms/Selectors they depend on update
Selectors can also write values of other Atoms and Selectors
Therefore:
for #1, we need some sugar around the core functions we need for the Atoms
#2 forces us to find out the Atoms and the Selectors a Selector depends on and subscribe it to them
#3 leverages the existing Atom setters and nothing much more
The gist of it: at the core of the project, a store must collect values and subscribers; subscribers can be components using the provided hooks or selectors.
Code, please!
You can play with the project on CodeSandbox or fork it on GitHub. Below, I’ll guide you through the relevant code split by context: core/public types, core/public API, and the hooks.
Types
If you want to skip the explanation: go directly to the typings.ts file on GitHub.
We will describe the exposed API/types later. Let’s concentrate on what data we need to store internally first. I’ll distinguish internal functions from the public ones prefixing them with core
.
CoreRecoilValue
The internal Recoil value. Every Atom should:
have an identifying key
have a default value
have a current value
have a list of subscribers to notify when it updates
While every Selector should:
have an identifying key
have a list of subscribers to notify when it updates
So the signature of CoreRecoilValue
is the following:
// @see https://github.com/NoriSte/recoil-apis
/*
* The internally stored Recoil values
*/
export type CoreRecoilValue<T> = {
key: string;
subscribers: Subscriber[];
} & (
| {
type: "atom";
default: T;
value: T;
}
| {
type: "selector";
}
);
export type Subscriber = () => void;
Please note that:
we don’t need to pass
T
while creating the Atom because TypeScript could infer it from the default valuethere are multiple ways to design this type, but I think discriminated unions are quite concise and clear
RecoilStores
Every RecoilRoot
must have a dedicated store and a unique id. A RecoilStore
is a Record
that identifies the Recoil values by their key and RecoilStores is a Record that stores every RecoilStore by their Recoil id. It’s easier to see the code 😊
// @see https://github.com/NoriSte/recoil-apis
export type RecoilStores = Record<
string,
Record<string, CoreRecoilValue<unknown>>
>;
Public types
There’s nothing to say about public types since I’m replicating Recoil’s type definitions 😊
// @see https://github.com/NoriSte/recoil-apis
export type Atom<T> = { key: string; default: T };
export type Selector<T> = {
key: string;
get: ({ get }: { get: GetRecoilValue }) => T;
set?: (
{
get,
set
}: {
get: GetRecoilValue;
set: SetRecoilValue;
},
nextValue: T
) => void;
};
export type RecoilValue<T> = Atom<T> | Selector<T>;
/**
* Recoil id-free functions
*/
type GetRecoilValue = <T>(recoilValue: RecoilValue<T>) => T;
type SetRecoilValue = <T>(recoilValue: RecoilValue<T>, nextValue: T) => void;
You can take a look at the whole typings.ts file on GitHub.
Core API
If you want to skip the explanation: go directly to the core.ts file on GitHub.
A mix of internal API and utilities, the end-user will not know about them.
Getters
The most effortless functions are the getters. They should:
retrieve the current value of Atoms from the
RecoilStore
or call theselector.get
functionexpose a generic
coreGetRecoilValue
that consumes the above-mentioned specialized gettersa higher-order function (
createPublicGetRecoilValue
) that allows leveraging thecoreGetRecoilValue
without knowing the recoil id
// @see https://github.com/NoriSte/recoil-apis
/**
* Get the current Recoil Atom' value
*/
const coreGetAtomValue = <T>(recoilId: string, atom: Atom<T>): T => {
const coreRecoilValue = getRecoilStore(recoilId)[atom.key];
// type-safety
if (coreRecoilValue.type !== "atom") {
throw new Error(`${coreRecoilValue.key} is not an atom`);
}
return (coreRecoilValue.value as any) as T;
};
/**
* Get the current Recoil Selector' value
*/
const coreGetSelectorValue = <T>(recoilId: string, selector: Selector<T>): T =>
selector.get({ get: createPublicGetRecoilValue(recoilId) });
/**
* Get the current Recoil Value' value
*/
export const coreGetRecoilValue = <T>(
recoilId: string,
recoilValue: RecoilValue<T>
): T =>
isAtom(recoilValue)
? coreGetAtomValue(recoilId, recoilValue)
: coreGetSelectorValue(recoilId, recoilValue);
/**
* Create a function that get the current Recoil Value' value
* @private
*/
export const createPublicGetRecoilValue = <T>(recoilId: string) => (
recoilValue: RecoilValue<T>
): T => coreGetRecoilValue(recoilId, recoilValue);
Please note:
Core functions are marked as
@private
to enforce the idea that the end-user must not import them.the
registerRecoilValue
is the first point of contact with the active Recoil store (the one the component resides in) and the Atom itself
If you are not familiar with the higher-order functions pattern, it’s a way to pre-configure a function you need to call later. Moreless all the core functions need to know the Recoil id, but, at the same time, their public counterpart needs to hide the id.
Here is a super-concise (without arrow functions) Gist illustrating the idea. Take a look at the logId
and the logIdWithoutKnowingIt
functions, they do the same thing, but the latter doesn’t require the id.
// @see https://github.com/NoriSte/recoil-apis
// core function, it requires the id
function logId(id: string) {
console.log(id);
}
// create a new function that accesses the id thanks to its closure
function createLogid(id: string) {
// pubklic functions, it doesn't require the id
return function () {
logId(id);
};
}
// you can log the iod only if you know it
logId("1");
// higher-order function creation
const logIdWithoutKnowingIt = createLogid("1");
// you can log the id without knowing it
logIdWithoutKnowingIt();
Setters
The basic set functionalities are:
setting an Atom new value
invoking all the subscribers
in the case of Selectors, calling their
set
function (if defined)exposing the usual Recoil id-free functions
Here’s the code:
// @see https://github.com/NoriSte/recoil-apis
/**
* Provide a Recoil Value setter
* @private
*/
const coreSetRecoilValue = <T>(
recoilId: string,
recoilValue: RecoilValue<T>,
nextValue: T
) => {
if (isAtom(recoilValue)) {
coreSetAtomValue(recoilId, recoilValue, nextValue);
} else if (recoilValue.set) {
recoilValue.set(
{
get: createPublicGetRecoilValue(recoilId),
set: createPublicSetRecoilValue(recoilId)
},
nextValue
);
}
};
/**
* Set the Recoil Atom and notify the subscribers without passing the recoil id
*/
const coreSetAtomValue = <T>(
recoilId: string,
recoilValue: RecoilValue<T>,
nextValue: T
) => {
const coreRecoilValue = getRecoilStore(recoilId)[recoilValue.key];
if (coreRecoilValue.type !== "atom") {
throw new Error(`${coreRecoilValue.key} is not an atom`);
}
if (nextValue !== coreRecoilValue.value) {
coreRecoilValue.value = nextValue;
coreRecoilValue.subscribers.forEach((callback) => callback());
}
};
/**
* Create a function that provide a Recoil Value setter
* @private
*/
export const createPublicSetRecoilValue = <T>(recoilId: string) => (
recoilValue: RecoilValue<T>,
nextValue: T
) => coreSetRecoilValue(recoilId, recoilValue, nextValue);
/**
* Create a function that sets the Recoil Atom and notify the subscribers without passing the recoil id
* @private
*/
export const createPublicSetAtomValue = <T>(
recoilId: string,
recoilValue: RecoilValue<T>
) => (nextValue: T) => coreSetAtomValue(recoilId, recoilValue, nextValue);
Registration and Subscription
So far everything is plain Vanilla JS, let’s jump into React’s domain: registration and subscription.
Registration must be idempotent (the effect of calling it once or more times must be the same). Why? Because we must register Recoil Values when we need it (because of the Recoil Id stored in the React Context that we can’t know in advance) and the possible options are:
checking if the Recoil Value is already registered before trying to register it
calling the
registerRecoilValue
without caring about previous registration, it does check itself
I opted for the latter, making registerRecoilValue
idempotent.
The subscribeToRecoilValueUpdates
must only return an unsubscriber, here the code for both registration and subscription:
// @see https://github.com/NoriSte/recoil-apis
/**
* Register a new Recoil Value idempotently.
* @private
*/
export const registerRecoilValue = <T>(
recoilId: string,
recoilValue: RecoilValue<T>
) => {
const { key } = recoilValue;
const recoilStore = getRecoilStore(recoilId);
// the Recoil values must be registered at runtime because of the Recoil id
if (recoilStore[key]) {
return;
}
if (isAtom(recoilValue)) {
recoilStore[key] = {
type: "atom",
key,
default: recoilValue.default,
value: recoilValue.default,
subscribers: []
};
} else {
recoilStore[key] = {
type: "selector",
key,
subscribers: []
};
}
};
/**
* Subscribe to all the updates of a Recoil Value.
* @private
*/
export const subscribeToRecoilValueUpdates = (
recoilId: string,
key: string,
callback: Subscriber
) => {
const recoilValue = getRecoilStore(recoilId)[key];
const { subscribers } = recoilValue;
if (subscribers.includes(callback)) {
throw new Error("Already subscribed to Recoil Value");
}
subscribers.push(callback);
const unsubscribe = () => {
subscribers.splice(subscribers.indexOf(callback), 1);
};
return unsubscribe;
};
You can take a look at the whole core.ts file on GitHub.
React API
If you want to skip the explanation: go directly to the api.ts file on GitHub.
useRecoilValue and useRecoilState
Here the basic API. useRecoilValue
must:
retrieve the current Recoil Id from the React Context created by the RecoilRoot component
register the Recoil Value
subscribe the component to every Atom/Selector update (like the official counterpart does)
get the current Recoil Value’ value (like the official counterpart does)
useRecoilState
leverages useRecoilValue
to retrieve the current value, then it must
provide a setter for Atoms
provide a setter for Selectors that means invoking the Selector’
set
, if defined, passing it both a Recoil Value getter and a setter
That’s the first use of the higher-order functions we defined earlier. Remember that we need to let the consumer access some core functionalities (like setting an Atom) hiding the Recoil Id, that’s why we created the createPublicGetRecoilValue
and the createPublicSetRecoilValue
.
Here’s the code
// @see https://github.com/NoriSte/recoil-apis
/**
* Recoil-like atom creation.
*/
export const atom = <T>(atom: Atom<T>) => {
// ...
return atom;
};
/**
* Recoil-like selector creation.
*/
export const selector = <T>(selector: Selector<T>) => {
// ...
return selector;
};
/**
* Subscribe to all the Recoil Values updates and return the current value.
*/
export const useRecoilValue = <T>(recoilValue: RecoilValue<T>) => {
const recoilId = useRecoilId();
const [, forceRender] = useReducer((s) => s + 1, 0);
// registering a Recoil value requires the recoil id (stored in a React Context),
// That's why it can't be registered outside a component/hook code. `registerRecoilValue`
// must be idempotent
registerRecoilValue(recoilId, recoilValue);
useSubscribeToRecoilValues(recoilValue, forceRender);
return coreGetRecoilValue(recoilId, recoilValue);
};
/**
* Subscribe to all the Recoil Values updates and return both the current value and a setter.
*/
export const useRecoilState = <T>(recoilValue: RecoilValue<T>) => {
const recoilId = useRecoilId();
const currentValue = useRecoilValue(recoilValue);
if (isAtom(recoilValue)) {
const setter = createPublicSetAtomValue(recoilId, recoilValue);
return [currentValue, setter] as const;
} else {
const setter = (nextValue: T) => {
if (recoilValue.set)
recoilValue.set(
{
get: createPublicGetRecoilValue(recoilId),
set: createPublicSetRecoilValue(recoilId)
},
nextValue
);
};
return [currentValue, setter] as const;
}
};
/**
* Get the Recoil id of the current components tree.
*/
const useRecoilId = () => {
const recoilId = useContext(RecoilContext);
if (!recoilId) {
throw new Error("Wrap your app with <RecoilRoot>");
}
return recoilId;
};
Subscription
The last missing bit is how the component subscribes to Recoil Value updates and how it unsubscribes. This necessary behavior comes for free with the React.useEffect
hook. The tricky part is getting the dependencies’ tree from a Selector and subscribing to every Recoil Value update: createDependenciesSpy
does it.
// @see https://github.com/NoriSte/recoil-apis
type Callback = () => void;
/**
* Subscribe/unsubscribe to all the updates of the involved Recoil Values
*/
const useSubscribeToRecoilValues = <T>(
recoilValue: RecoilValue<T>,
callback: Callback
) => {
const recoilId = useRecoilId();
useEffect(() => {
if (isAtom(recoilValue)) {
return subscribeToRecoilValueUpdates(recoilId, recoilValue.key, callback);
} else {
const dependencies: string[] = [];
recoilValue.get({ get: createDependenciesSpy(recoilId, dependencies) });
const unsubscribes: Callback[] = [];
dependencies.forEach((key) =>
unsubscribes.push(
subscribeToRecoilValueUpdates(recoilId, key, callback)
)
);
return () => unsubscribes.forEach((unsubscribe) => unsubscribe());
}
}, [recoilId, recoilValue, callback]);
};
/**
* Figure out the dependencies tree of each selector.
* Please note: it doesn't support condition-based dependencies tree.
*/
const createDependenciesSpy = (recoilId: string, dependencies: string[]) => {
const dependenciesSpy = (recoilValue: RecoilValue<any>) => {
dependencies.push(recoilValue.key);
if (isAtom(recoilValue)) {
return coreGetRecoilValue(recoilId, recoilValue);
} else {
return recoilValue.get({ get: dependenciesSpy });
}
};
return dependenciesSpy;
};
You can take a look at the whole api.ts file on GitHub.
RecoilRoot
The parent of every Recoil tree. Its sole task is creating the Recoil Context around the children that consumes the Recoil Values. The code is straightforward.
// @see https://github.com/NoriSte/recoil-apis
import type { FC } from "react";
import * as React from "react";
import { createContext } from "react";
import { generateRecoilId } from "./core";
export const RecoilContext = createContext("");
export const RecoilRoot: FC = (props) => {
const recoilId = generateRecoilId();
return (
<RecoilContext.Provider value={recoilId}>
{props.children}
</RecoilContext.Provider>
);
};
It works!
Here we are! You can play with the project on CodeSandbox or fork it on GitHub. The App.tsx code stresses what mentioned above:
registering some Atoms and Selectors, the same way you would do with Recoil
registering Selectors that depends on Atoms and Selectors
providing a Selector’ set
logging every re-render of the components because you can check with your eyes that everything updates as expected while using the app, but you can’t check how many times the components are re-rendered, so take a look at the console
Tests
I don’t want only to check that everything works as expected for this project’s sake, but I want to be sure that components don’t re-render more than expected. I needed to do that so often during the project’s development that I’ve written some tests to automate my manual checks.
FAQs
Are there some untested scenarios in the above code?
Yep, I manually tested that everything works when
a component dynamically changes the Recoil Values it is subscribed to
a Selector sets another Selector
Why double quotes and semicolons?
I didn’t care about customizing the default CodeSandbox’ Prettier configuration 😉
Are there other approaches out there?
Sure, take a look at Bennett Hardwick’ “Rewriting Facebook’s “Recoil” React library from scratch in 100 lines” article. He uses a more class-based approach. Please check it out!
Conclusions
I liked re-implementing the Recoil API because
it forced me to take a look at the Recoil internals
it exposed me to different problems and so think about wider solutions
it exposed me to creating a new type of blog post where I try to explain some design decisions, my previous articles were much about problems and solutions, telling my experience, or trying to inspire people
I didn’t like doing that because
- I know that working on side-projects is not ideal for me, I’m sometimes too much of a perfectionist 😁
Other articles of mine you could find interesting
My journey with Cypress: Choose what NOT to study and focus on one thing at a time
Why tests are perfect to tell a story of your code: Software tests as a documentation tool
An easier approach to get immediate results (and satisfaction) from the front-end testing world: New to front-end testing? Start from the top of the pyramid!
And don’t forget to take a look at my UI Testing Best Practices book on GitHub 😊
Top comments (0)