Reimplementing the core Recoil's APIs for fun and learning

noriste

Stefano Magni

Posted on April 21, 2021

Reimplementing the core Recoil's APIs for fun and learning

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 atoms

  • implementing the selector API to create new selectors that depend on atoms and other selectors

  • implementing the useRecoilValue API to get an atom/selector value and subscribing (aka getting re-rendered) to their update

  • implementing the useRecoilState API to get all useRecoilValue features plus setting the atom/selector

  • implementing 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:

  1. storing the values of the Atoms: the atoms themselves are just plain objects, the Recoil store must keep their current state

  2. updating the subscribed components when an Atom updates and forcing them to re-render

  3. exposing API through React hooks

  4. differentiate every store with an id since every RecoilRoot is independent

  5. 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({});
Enter fullscreen mode Exit fullscreen mode

while Redux, internally, does it through useReducer

    const [, forceRender] = useReducer(s => s + 1, 0)
Enter fullscreen mode Exit fullscreen mode
  • 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?

  1. Selectors are stateless. Their get are pure functions that derive their values from other Atoms and Selectors

  2. Selectors must update when one of the Atoms/Selectors they depend on update

  3. 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;
Enter fullscreen mode Exit fullscreen mode

Please note that:

  • we don’t need to pass T while creating the Atom because TypeScript could infer it from the default value

  • there 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>>
>;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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 the selector.get function

  • expose a generic coreGetRecoilValue that consumes the above-mentioned specialized getters

  • a higher-order function (createPublicGetRecoilValue) that allows leveraging the coreGetRecoilValue 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);
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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

And don’t forget to take a look at my UI Testing Best Practices book on GitHub 😊

💖 💪 🙅 🚩
noriste
Stefano Magni

Posted on April 21, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

What was your win this week?
weeklyretro What was your win this week?

November 29, 2024

Where GitOps Meets ClickOps
devops Where GitOps Meets ClickOps

November 29, 2024

How to Use KitOps with MLflow
beginners How to Use KitOps with MLflow

November 29, 2024

Modern C++ for LeetCode 🧑‍💻🚀
leetcode Modern C++ for LeetCode 🧑‍💻🚀

November 29, 2024