Koki Nagai

/

🌞

Deep inside useRecoilState

This time, I'd like to look at the internal implementation of the set◯◯ (update function) returned by the useRecoilState API from Recoil.

As an example, let's prepare an atom and useRecoilState like this:

1const hogeAtom = atom({
2  key: 'hogeKey',
3  default: 1
4});
5const [hoge, setHoge] = useRecoilState(hogeAtom);

Let's start by looking inside useRecoilState.

1function useRecoilState(recoilState) {
2  if (process.env.NODE_ENV !== 'production') {
3    // $FlowFixMe[escaped-generic]
4    validateRecoilValue(recoilState, 'useRecoilState');
5  }
6
7  return [useRecoilValue(recoilState), useSetRecoilState(recoilState)];
8}

Here, it seems to be checking whether the state is of the correct type only in non-production environments.

Looking inside useSetRecoilState, it appears to be returning a function memoized with useCallback. It takes newValueOrUpdater as an argument, which is the value passed when calling setHoge.

It passes that value, along with storeRef.current and recoilState, to setRecoilValue.

1function useSetRecoilState(recoilState) {
2  if (process.env.NODE_ENV !== 'production') {
3    // $FlowFixMe[escaped-generic]
4    validateRecoilValue(recoilState, 'useSetRecoilState');
5  }
6
7  const storeRef = useStoreRef$1();
8  return useCallback$1(
9    (newValueOrUpdater) => {
10      setRecoilValue$2(storeRef.current, recoilState, newValueOrUpdater);
11    },
12    [storeRef, recoilState]
13  );
14}

storeRef.current seems to be a context object created with the context API, with the initial value containing things like getState.

1const AppContext = react.createContext({
2  current: defaultStore
3});
4
5const useStoreRef = () => useContext(AppContext);
6function notInAContext() {
7  throw new Error(
8    'This component must be used inside a <RecoilRoot> component.'
9  );
10}
11
12const defaultStore = Object.freeze({
13  getState: notInAContext,
14  replaceState: notInAContext,
15  getGraph: notInAContext,
16  subscribeToTransactions: notInAContext,
17  addTransactionMetadata: notInAContext
18});

Looking back at useSetRecoilState, it's passing recoilState, which is the atom value passed to useRecoilState.

1return useCallback$1(
2  (newValueOrUpdater) => {
3    setRecoilValue$2(storeRef.current, recoilState, newValueOrUpdater);
4  },
5  [storeRef, recoilState]
6);

Next, let's look inside setRecoilValue$2.

1function setRecoilValue(store, recoilValue, valueOrUpdater) {
2  queueOrPerformStateUpdate(store, {
3    type: 'set',
4    recoilValue,
5    valueOrUpdater
6  });
7}

It simply wraps and executes the queueOrPerformStateUpdate function.

Inside queueOrPerformStateUpdate:

1function queueOrPerformStateUpdate(store, action) {
2  if (batchStack.length) {
3    const actionsByStore = batchStack[batchStack.length - 1];
4    let actions = actionsByStore.get(store);
5
6    if (!actions) {
7      actionsByStore.set(store, (actions = []));
8    }
9
10    actions.push(action);
11  } else {
12    applyActionsToStore(store, [action]);
13  }
14}
15
16const batchStack = [];

To simplify, since batchStack.length is initially an empty array, it executes applyActionsToStore(store, [action]).

1function applyActionsToStore(store, actions) {
2  store.replaceState((state) => {
3    const newState = copyTreeState(state);
4
5    for (const action of actions) {
6      applyAction(store, newState, action);
7    }
8
9    invalidateDownstreams(store, newState);
10    return newState;
11  });
12}

It calls replaceState on the store (context object), and while I haven't read the details, it seems to be creating a new state based on the store.

It then passes the created new state to applyAction.

Looking at applyAction:

1function applyAction(store, state, action) {
2  if (action.type === 'set') {
3    const {
4      recoilValue,
5      valueOrUpdater
6    } = action;
7    const newValue = valueFromValueOrUpdater(store, state, recoilValue, valueOrUpdater);
8    const writes = setNodeValue$1(store, state, recoilValue.key, newValue);
9
10    for (const [key, loadable] of writes.entries()) {
11      writeLoadableToTreeState(state, key, loadable);
12    }
13  }
14  // ...

Looking inside valueFromValueOrUpdater, it seems that if valueOrUpdater is not a function, it simply returns the update value. In our case, since it's 100, it will return 100.

1function valueFromValueOrUpdater(store, state, { key }, valueOrUpdater) {
2  if (typeof valueOrUpdater === 'function') {
3    // Updater form: pass in the current value. Throw if the current value
4    // is unavailable (namely when updating an async selector that's
5    // pending or errored):
6    const current = getNodeLoadable$1(store, state, key);
7
8    if (current.state === 'loading') {
9      throw new RecoilValueNotReady$1(key);
10    } else if (current.state === 'hasError') {
11      throw current.contents;
12    } // T itself may be a function, so our refinement is not sufficient:
13
14    return valueOrUpdater(current.contents); // flowlint-line unclear-type:off
15  } else {
16    return valueOrUpdater;
17  }
18}

Next, looking inside setNodeValue$1, it has this implementation:

1function setNodeValue(store, state, key, newValue) {
2  const node = getNode$1(key);
3
4  if (node.set == null) {
5    throw new ReadOnlyRecoilValueError(
6      `Attempt to set read-only RecoilValue: ${key}`
7    );
8  }
9
10  const set = node.set; // so flow doesn't lose the above refinement.
11
12  initializeNodeIfNewToStore(store, state, key, 'set');
13  return set(store, state, newValue);
14}

First,

1const node = getNode$1(key);

seems to be retrieving the value from a Map object called nodes, using the key as the key. This nodes object seems to be created when creating atoms, as something like:

1[
2  [
3    key: "hogeKey",
4    value: {
5      "key": "hogeKey",
6      "shouldRestoreFromSnapshots": true,
7      "retainedBy": "recoilRoot",
8      "get: getAtom
9      "set": setAtom(store, state, newValue)
10    }
11  ]
12]

In setNodeValue, it returns the set function from the value, so it ends up executing setAtom(store, state, newValue).

Looking at setAtom, from the line return new Map().set(key, loadableWithValue$2(newValue));, it seems to be returning a new Map object with the key and a new value created by loadableWithValue.

1function setAtom(_store, state, newValue) {
2  // Bail out if we're being set to the existing value, or if we're being
3  // reset but have no stored value (validated or unvalidated) to reset from:
4  if (state.atomValues.has(key)) {
5    const existing = Recoil_nullthrows(state.atomValues.get(key));
6
7    if (existing.state === 'hasValue' && newValue === existing.contents) {
8      return new Map();
9    }
10  } else if (
11    !state.nonvalidatedAtoms.has(key) &&
12    newValue instanceof DefaultValue$2
13  ) {
14    return new Map();
15  }
16
17  if (process.env.NODE_ENV !== 'production') {
18    if (options.dangerouslyAllowMutability !== true) {
19      Recoil_deepFreezeValue(newValue);
20    }
21  }
22
23  cachedAnswerForUnvalidatedValue = undefined; // can be released now if it was previously in use
24
25  return new Map().set(key, loadableWithValue$2(newValue));
26}
27
28// ...
1function loadableWithValue(value) {
2  // Build objects this way since Flow doesn't support disjoint unions for class properties
3  return Object.freeze({
4    state: 'hasValue',
5    contents: value,
6    ...loadableAccessors,
7
8    getValue() {
9      return this.contents;
10    },
11
12    toPromise() {
13      return Promise.resolve(this.contents);
14    },
15
16    valueMaybe() {
17      return this.contents;
18    },
19
20    valueOrThrow() {
21      return this.contents;
22    }
23  });
24}

So going back to applyAction:

1const writes = setNodeValue$1(store, state, recoilValue.key, newValue);
2
3for (const [key, loadable] of writes.entries()) {
4  writeLoadableToTreeState(state, key, loadable);
5}

The writes variable is the Map object returned by setAtom.

Finally, looking inside writeLoadableToTreeState:

1function writeLoadableToTreeState(state, key, loadable) {
2  if (
3    loadable.state === 'hasValue' &&
4    loadable.contents instanceof DefaultValue$1
5  ) {
6    state.atomValues.delete(key);
7  } else {
8    state.atomValues.set(key, loadable);
9  }
10
11  state.dirtyAtoms.add(key);
12  state.nonvalidatedAtoms.delete(key);
13}

It seems to be updating the atomValues in the state by setting the key and loadable value.

Summary

While Recoil was developed by Facebook as a potential replacement for Redux or Context, it's still an experimental release at version 0.3, and there doesn't seem to be a clear roadmap yet. This means we may see breaking changes going forward. If using it in production, we'll need to consider these factors before adoption.

Koki Nagai