Source: Facebook’s “Recoil” React Library from Scratch in 100 lines

Translator: Tashi

Protocol: CC BY-NC-SA 4.0

Atoms

Recoil is built around the concept of Atoms. Atoms are part of the atomicity that makes up the overall state, and you can subscribe to it or change its value in the component.

To start, I’ll create a class called Atom to wrap around some values T. I’ve added helper methods update and Snapshot that allow you to get or change the value of Atom.

class Atom<T> {
  constructor(private value: T) {}

  update(value: T) {
    this.value = value;
  }

  snapshot(): T {
    return this.value; }}Copy the code

To be able to listen for state changes, you need to use observer mode. This pattern is common in libraries like RxJS, but we’ll write a simple synchronized version from scratch to make it easier to understand.

To know who is listening to the state, I use Set to store the callback for listening. A Set (or Hash Set) is a data structure that stores unique values. In JavaScript, it can be easily converted to an array, with some helpful methods for efficiently adding or removing values.

You can add a listener through the Subscrible method. The Subscrible method returns a Disconnecter – an interface with a method to stop listening. This method is called when a React component is uninstalled and you no longer want to listen for state changes.

Next, a method called emit was added. This method iterates through all listener functions, passing them the currently stored value.

Finally, we overwrite the update method so that we emit when the new value is set.

type Disconnecter = { disconnect: () = > void };

class Atom<T> {
  private listeners = new Set<(value: T) = > void> ();constructor(private value: T) {}

  update(value: T) {
    this.value = value;
    this.emit();
  }

  snapshot(): T {
    return this.value;
  }

  emit() {
    for (const listener of this.listeners) {
      listener(this.snapshot());
    }
  }

  subscribe(callback: (value: T) = > void): Disconnecter {
    this.listeners.add(callback);
    return {
      disconnect: () = > {
        this.listeners.delete(callback); }}; }}Copy the code

Shout!

It’s time to wire the Atom and React components together. To do this, I create a hook called useCoiledValue. (Sound familiar?)

The hook returns the current state value stored by Atom, listens for changes, and rerenders when the state value changes. When the hook is unloaded, it clears the listener function that was set up at the beginning.

The updateState hook can be a little strange. Set a new ({}) reference to updateState and React will re-render the component. This may be a bit of a hack, but it’s a simple and effective way to ensure that components are always rerendered (when atom values are updated).

export function useCoiledValue<T> (value: Atom<T>) :T {
  const [, updateState] = useState({});

  useEffect(() = > {
    const { disconnect } = value.subscribe(() = > updateState({}));
    return () = > disconnect();
  }, [value]);

  return value.snapshot();
}
Copy the code

Next, I added a useCoiledState method. Its API is much like useState – it gives you the latest values atom currently stores and allows you to reset them.

export function useCoiledState<T> (atom: Atom<T>) :T, (value: T) = >void] {
  const value = useCoiledValue(atom);
  return [value, useCallback((value) = > atom.update(value), [atom])];
}
Copy the code

Now that we’ve understood these hooks, it’s time to look at Selectors. Before we do that, let’s refactor the previous code.

Like Atom, a selector can be thought of as a value with state. To make it easier to implement selectors, I first pumped most of the logic out of Atom into a base class called Stateful.

class Stateful<T> {
  private listeners = new Set<(value: T) = > void> ();constructor(private value: T) {}

  protected _update(value: T) {
    this.value = value;
    this.emit();
  }

  snapshot(): T {
    return this.value;
  }

  subscribe(callback: (value: T) = > void): Disconnecter {
    this.listeners.add(callback);
    return {
      disconnect: () = > {
        this.listeners.delete(callback); }}; }}class Atom<T> extends Stateful<T> {
  update(value: T) {
    super._update(value); }}Copy the code

Come on!

Selectors

Selector is Recoil’s version of a “calculated attribute” or “reducers”. In their own words

A selector represents a list of derived states. You can think of the derived state as some kind of state-passing pure function that you modify inside and then output

The API for selectors in Recoil is simple: you create an object with a get method, and the return value of GET is your current state value. Inside the get method, you can subscribe to other state values, and when they update, your selector updates as well.

In our version, I renamed the GET method to Generator. I call it that because essentially the Generator is a factory function that produces new state values based on incoming state.

In the code, we annotate the Generator method with the following function signature

type SelectorGenerator<T> = (context: GeneratorContext) = > T;
Copy the code

For those of you unfamiliar with Typescript, this is a function that takes a GeneratorContext as an argument and returns a value of type T. And that return value is going to be the state value stored inside the selector.

What does the GeneratorContext object do?

It allows selectors to access other state values and calculate their own state values based on them. From now on, we will call these other state values “dependencies”

interface GeneratorContext {
  get: <V>(dependency: Stateful<V>) = > V
}
Copy the code

Any time someone calls the get method on GeneratorContext, the accessed state value is added to the dependency array as a dependency. And that means that when any of the dependencies are updated, the selector is also updated.

Here’s what a production function that creates a selector looks like

function generate(context) {
  // Register the NameAtom as a dependency
  // and get it's value
  const name = context.get(NameAtom);
  // Do the same for AgeAtom
  const age = context.get(AgeAtom);

  // Return a new value using the previous atoms
  // E.g. "Bob is 20 years old"
  return `${name} is ${age} years old.`;
};
Copy the code

So let’s put the production function aside, and let’s do a Selector class. The class should take a production function as an argument to the constructor and then use the getDep method on the class to get the value stored by the dependent Atom.

You may have noticed that I wrote super(undefined as any) in the constructor. This is because the super keyword must be the first line of the derived class constructor. If it helps, here you can think of undefined as representing uninitialized memory.

export class Selector<T> extends Stateful<T> {
  private getDep<V>(dep: Stateful<V>): V {
    return dep.snapshot();
  }

  constructor(
    private readonly generate: SelectorGenerator<T>
  ) {
    super(undefined as any);
    const context = {
      get: dep= > this.getDep(dep) 
    };
    this.value = generate(context); }}Copy the code

This selector can only produce the state value once. In order to be able to update state values as dependencies change, we need to subscribe to those dependencies.

To do this, let’s update the getDep method to subscribe to the dependency and call the updateSelector method. To ensure that the selector is updated only once for every change to a dependency, we put those dependencies into a Set to track.

The updateSelector method is similar to the constructor of the previous example. It creates a GeneratorContext, executes the generate method, and then calls the Update method which was created from the base class Stateful.

export class Selector<T> extends Stateful<T> {
  private registeredDeps = new Set<Stateful>();

  private getDep<V>(dep: Stateful<V>): V {
    if (!this.registeredDeps.has(dep)) {
      dep.subscribe(() = > this.updateSelector());
      this.registeredDeps.add(dep);
    }

    return dep.snapshot();
  }

  private updateSelector() {
    const context = {
      get: dep= > this.getDep(dep)
    };
    this.update(this.generate(context));
  }

  constructor(
    private readonly generate: SelectorGenerator<T>
  ) {
    super(undefined as any);
    const context = {
      get: dep= > this.getDep(dep) 
    };
    this.value = generate(context); }}Copy the code

It’s almost done. Recoil has some helper functions to help create atoms and selectors. Because most JavaScript developers think classes are evil, they can help cover up our SINS.

One is used to create atom

export function atom<V> (
  value: { key: string; default: V }
) :Atom<V> {
  return new Atom(value.default);
}
Copy the code

One for creating a selector

export function selector<V> (value: {
  key: string;
  get: SelectorGenerator<V>;
}) :Selector<V> {
  return new Selector(value.get);
}
Copy the code

By the way, remember the useCoiledValue Hook? We refactor it to accept selectors as well:

export function useCoiledValue<T> (value: Stateful<T>) :T {
  const [, updateState] = useState({});

  useEffect(() = > {
    const { disconnect } = value.subscribe(() = > updateState({}));
    return () = > disconnect();
  }, [value]);

  return value.snapshot();
}
Copy the code

In this way! We’re done! 🎉

Praise yourself!

That’s it?

In the interest of brevity (and the ability to use “100 lines “as a headline to attract clicks), I’ve decided to omit reviews, tests, and examples. If you want a more thorough explanation (or want to try some examples), it’s all in my recoil-Clone Github repository.

Here’s another example site you can try

conclusion

I once read that all good software should be simple enough that anyone can rewrite it when they want to. Recoil still has a lot of features THAT I didn’t implement, but it’s exciting that such a clean and intuitive design can be reasonably implemented manually.

Before you decide to put my fake Recoil into production, make sure you know these things:

  • Selectors do not cancel listening on Atoms. This means memory leaks when you no longer use them.
  • React already introduced a guy calleduseMutableSourceThe hook. If you’re using the latest version of React, you should use it insteaduseCoiledValueIn thesetState 。
  • Selectors and Atoms will only make a shallow comparison before re-rendering. In some scenarios, it may make sense to use depth.
  • Recoil usekeyAttribute identifies each Atom or Selector, which is used as metadata to support “application-wide state observation.” The reason I use it here is to keep the API similar.
  • Recoil supports async functions in selectors, which would be a daunting task, so I left this feature out.

Beyond that, hopefully I’ve shown you that you don’t always have to rely on third-party libraries when deciding what state management solution to use. That way, you often won’t find a solution that works perfectly for you — that’s how Recoil was born, after all.

🏆 nuggets technical essay | double festival special articles