Recoil: onSet() handler in effect not triggered when atom initialized via RecoilRoot initializeState or snapshot

Created on 28 Nov 2020  路  11Comments  路  Source: facebookexperimental/Recoil

RecoilRoot using initializeState:

      <RecoilRoot
        initializeState={({ set }) => set(domainState, props.savedDomainState)}
      >
        <AppRoot root={props.root} />
      </RecoilRoot>

The Atom using an effect:

const domainState = atom({
  key: "domainState",
  default: null,
  effects_UNSTABLE: [syncDomainStorage],
});

The function: syncDomainStorage is only triggered on initial render when I use initializeState, only if I remove initializeState the side-effect function is called when the atom is updated.

Any updates to domainState atom should be triggering the effect, which is not the behavior observed when used along with RecoilRoot initializeState

bug

All 11 comments

@abacaj - I added a unit test for the interaction of atom effects and <RecoilRoot>'s initializeState prop in #771 and it appears to be working. Note that with the current implementation the effect is run first, then the global initializeState runs and thus takes precedence setting the initial atom value. Perhaps that is the discrepancy you are observing? Do you have a reproducer for your issue on codesandbox.io?

@drarmstr thanks for the quick reply.

Please see example here:
https://codesandbox.io/s/confident-framework-ib1kf?file=/src/index.js

I am appending to the DOM every time the effect is supposed to trigger:

import { atom } from "recoil";

function handleStateSideEffects({ onSet, setSelf, trigger }) {
  const textnode = document.createTextNode(`${trigger} - trigger called`);
  const node = document.createElement("div");
  node.appendChild(textnode);
  document.body.appendChild(node);

  onSet((newState) => {
    const textnode = document.createTextNode(`${newState} - effect called`);
    const node = document.createElement("div");
    node.appendChild(textnode);
    document.body.appendChild(node);
  });
}

export const appState = atom({
  key: "appState",
  default: { visitors: 0 },
  effects_UNSTABLE: [handleStateSideEffects]
});

There is only two times that it is appended. Any time you click the button, it should be appending to the DOM - unless effects are supposed to function that way and I misunderstand them?

The only time it works is when we remove initializeState={initializeState} from recoilRoot, after we remove it we can see that clicking the button keeps appending to the DOM because of onSet

Got it, so the effect is executing properly, but the issue is the onSet() handler being called when initialized via a snapshot.

any solutions?

I found a temporary solution, which is to skip the initializeState logic, and just use the regular useSetRecoilState before mounting the rest of the app.

This is really not ideal, but it does the trick for me, until the problem has been fixed.

Here's my Recoil Provider:

import localForage from 'localforage';
import React, { ReactNode, useEffect } from 'react';
import { RecoilRoot, useSetRecoilState } from 'recoil';

import { useLoading } from 'utils/hooks/loading';

import Loading from 'components/UI/loading/loading';

/** Recoil Provider and persistor */
const RecoilProvider = ({ children }: { children: ReactNode }) => {
  return (
    <RecoilRoot >
      <RecoilPersist>{children}</RecoilPersist>
    </RecoilRoot>
  );
};

export default RecoilProvider;

const RecoilPersist = ({ children }: { children: ReactNode }) => {
  const setAtom1 = useSetRecoilState(atom1);
  const setAtom2 = useSetRecoilState(atom2);
  const [isLoading, setIsLoading] = useState<boolean>(true);

  const persistedAtoms = [
    {
      key: 'atom1',
      setter: setAtom1,
    },
    {
      key: 'atom2',
      setter: setAtom2,
    },
  ];

  const loadPersistedAtoms = () => {
    const loadPersisted = async () => {
      try {
        for await (const atom of persistedAtoms) {
          const persistedData = await localForage.getItem<any>(atom.key);
          atom.setter(JSON.parse(persistedData));
        }

        setIsLoading(false);
      } catch (err) {
        setIsLoading(false);
        return;
      }
    };

    loadPersisted();
  };

  useEffect(loadPersistedAtoms, []);

  return <>{!isLoading ? children : <Loading />}</>;
};

FYI @kwoktung

A workaround for now could also be to just initialize the atom state in the effect and avoid using the initializeState prop. This is the preferred approach anyway for persistence as it can better handle dynamic atom families and multiple/composable persistence policies.

@drarmstr That sounds better. Would that be using the setSelf?
If not, can you you provide an example of how to set the initial value in the effect instead?

Awesome work guys 馃挭

@drarmstr Sorry, never mind I figure out by following the docs 馃し ...

I had the issue that I needed to await to get the value from localForage indexedDB, so I ended up wrapping it in a async function.

Here's how it ended

import localForage from 'localforage';
import { AtomEffect, DefaultValue } from 'recoil';

/** Check if there's an initial value persisted and load it on set  */
const loadPersisted = async <T>({ key, setSelf }: { key: string; setSelf: Parameters<AtomEffect<T>>['0']['setSelf'] }) => {
  const savedValue = await localForage.getItem<string>(key);

  if (savedValue != null) {
    setSelf(JSON.parse(savedValue));
  }
};

/**
 * Localstorage Atom Effect
 *
 * Add to `effects_UNSTABLE` to persist atom.
 * Side-effect for Atom Manipulating
 * @see https://recoiljs.org/docs/guides/atom-effects/
 */
export const persistAtomEffect = <T>(key: string): AtomEffect<T> => ({ setSelf, onSet }) => {
  loadPersisted({ key, setSelf });

  onSet(async (newValue) => {
    if (newValue instanceof DefaultValue) {
      localForage.removeItem(key);
    } else {
      localForage.setItem(key, JSON.stringify(newValue));
    }
  });
};

Thanks :)

Note that Atom Effect functions are themselves not async and should not return a Promise. You can schedule async calls to setSelf() in them, but that will only set the atom value asynchronously after initial render, not initialize the atom state for initial render (which would use the atom's default value). If you need to get the initial state asynchronously you could synchronously call setSelf() with an async Promise. This will cause the atom to be initialized in a pending state that will leverage Suspense for the initial render. The approach you want is up to you, but be aware of the differences.

const loadPersisted = <T>({ key, setSelf }) => {
  setSelf(localForage.getItem(key).then(JSON.parse));
};

@drarmstr Amazing, I was wondering how to approach this properly.
Thanks!! 馃挭

@drarmstr I've added a PR https://github.com/facebookexperimental/Recoil/pull/828 for an update docs with the asynchronous / promise examples we discussed.
I would have liked that when looking through the docs, so maybe other would as well.

Let me know if there's something badly explained.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

aappddeevv picture aappddeevv  路  3Comments

eLeontev picture eLeontev  路  3Comments

pesterhazy picture pesterhazy  路  4Comments

robsoncezario picture robsoncezario  路  3Comments

ibnumusyaffa picture ibnumusyaffa  路  4Comments