Recoil: [Proposal] Recoil Atom Actions

Created on 14 Sep 2020  路  13Comments  路  Source: facebookexperimental/Recoil

Let's consider such kind of situation: we have a theme in our application, and we want to have a shared state for that. With Recoil it's easier than ever before:

import { atom } from 'recoil';

const themeState = atom({
  key: 'themeState',
  default: 'dark',
});

That's great! Now two more things about further usage of the theme.

In our application, we will toggle theme and the current state of the theme should be synchronized with the localStorage (the initial value should be taken from the localStorage if there is one)

Okay, we can change our code a little bit:

import { atom } from 'recoil';

const themeState = atom({
  key: 'themeState',
  default: localStorage.getItem('theme-mode') || 'dark', // it's not so good, and should be updated in the future
});

Now, this atom can be used in different components, and inside those components, toggle can be produced. For example:

SomeComponent.js

import { useRecoilState } from 'recoil';
import { themeState } from '...';

import { themePair } from 'config';

function SomeComponent() {
  const [theme, setTheme] = useRecoilState(themeState);

  function handleThemeToggle() {
    const mode = theme === themePair[0] ? themePair[1] : themePair[0];
    setTheme(mode);
    localStorage.setItem('theme-mode', theme);
  }

  // ...

  return ... ;
}

The big problem here, in my opinion, is the unpredictability of the state updates and the lack of "structurdness". Our atom can be used in all components, and the value can be changed whether a developer wants. The state changes are not predictable. To handle this I created a single entry point for each shared state. In the case of theme it looks like this:

store/theme/index.js

import { atom, useRecoilState } from 'recoil';

import { themePair } from 'config';

const themeState = atom({
  key: 'themeState',
  default: localStorage.getItem('theme-mode') || 'dark',
});

function useTheme() {
  const [theme, setTheme] = useRecoilState(themeState);

  function toggle() {
    const mode = theme === themePair[0] ? themePair[1] : themePair[0];
    setTheme(mode);
    localStorage.setItem('theme-mode', theme);
  }

  return [theme, { toggle }];
}

export default useTheme;

Now, the interaction with the theme will look like this:

SomeComponent.js

import { useTheme } from 'store/theme';

function SomeComponent() {
  const [theme, actions] = useTheme();

  function handleThemeToggle() {
    actions.toggle();
  }

  // ...

  return ... ;
}

This looks much much better. Now, the shared state and the possible interactions with it are isolated; it's more predictable and testable. But still not the ideal.

Effects

Recently "Atom Effects" has been announced. That's a really great feature and gives us some important opportunities. In our case, we can handle the synchronization with localStorage. Let's change our code a little bit.

import { atom, useRecoilState } from 'recoil';

import { themePair } from 'config';

const themeState = atom({
  key: 'themeState',
  default: 'dark',

  effects: [
    ({ setSelf,  }) => {
      localStorage.getItem('theme-mode') && setSelf(localStorage.getItem('theme-mode')); // the initial value

      onSet(value => localStorage.setItem('theme-mode', value));
    },
  ],
});

function useTheme() {
  const [theme, setTheme] = useRecoilState(themeState);

  function toggle() {
    const mode = theme === themePair[0] ? themePair[1] : themePair[0];
    setTheme(mode);
  }

  return [theme, { toggle }];
}

export default useTheme;

Actions

My proposal is to add Atom Actions; very similar to Atom Effects. It's an optional field in an atom and can define the possible interactions with the atom's state. If actions field is not empty useRecoilState will return [state, actions] pair instead of [state, setState]. If actions are not defined everything will work without any change. Let's change our code according to this:

import { atom, useRecoilState } from 'recoil';

import { themePair } from 'config';

const themeState = atom({
  key: 'themeState',
  default: 'dark',

  effects: [
    ({ setSelf,  }) => {
       localStorage.getItem('theme-mode') && setSelf(localStorage.getItem('theme-mode')); // the initial value

    onSet(value => localStorage.setItem('theme-mode', value));
    },
  ],

  actions: {
    toggle({ set }) {
       set(value => value === themePair[0] ? themePair[1] : themePair[0]);
    }
  },
});

export default themeState;

And it can be used like this:

SomeComponent.js

import { useRecoilState } from 'recoil';
import { themeState } from 'store/theme';

function SomeComponent() {
  const [theme, actions] = useRecoilState(themeState);

  function handleThemeToggle() {
    actions.toggle();
  }

  // ...

  return ... ;
}

The benefits of this are:

1) the possibility to make an isolated data layer
2) predefined actions for the atom
2.1) predictable state updates
2.2) readability

As we saw, we can do something like this with a custom hook (in this case useTheme), but in my opinion, it wasn't the best, purest, and clearest way. The person who is responsible for the data layer can define all possible shared states for the application with their possible updates without interacting with hooks or other specific tools; it's pure js - just define your state, effects, and actions. Based on that actions and effects, we can make a graph of possible updates, we can easily test and debug, and what is the most important thing we can keep everything predictable.

The above example is a real-life example, but there is just a single action. I think the difference can be more expressive in more complex examples.

It's just a week I started working with Recoil, and maybe there are better patterns for organizing shared states that I don't know. So, I am glad to hear your feedback. Thank you!

Most helpful comment

I'm still chewing on the details, but I will when if I come up with something that I feel can be useful in a general sense without being too opinionated/locked. Maybe you should have a section in the documentation where you collect common partterns/ways of using Recoil. I feel especially organizing one's data in a scaleable way both regarding discovery and cluttering is something that could use a good thinking about, and maybe a guide.

All 13 comments

I like the idea of being able to define any number of operations that can be performed, with any number of arguments.
My teams current solution (which I am not a big fan of) is to have a custom atom factory function.

We have a typewrapper for extra methods:

export type ExternalRecoilState<TData> = RecoilState<TData> & {
  invalidate: () => void;
};

And a method for creating atoms with external data (e.g. fetched data)

export function atomExternal<TData>(options: AtomOptions<TData>) {
  // https://github.com/facebookexperimental/Recoil/issues/422
  const _invalidatorAtom = atom({
    key: options.key + '_invalidator',
    default: 0,
  });

  const _externalSelector = selector<TData>({
    key: options.key + '_selector',
    get: async ({ get }) => {
      get(_invalidatorAtom);

      data = await options.get({ get });

      return data;
    }
  }) as ExternalRecoilState<TData>;

  _externalSelector.invalidate = () => RecoilSet(_invalidatorAtom, x => x + 1);

  return _externalSelector;
}

The RecoilSet function is not part of the offial api, but essentially it sets the value of an atom in our RecoilRoot.

It would be really cool to not have to create an extra atom to invalidate a cache.

@suren-atoyan - We don't want to change the type of the return value of the useRecoilState() dynamically based on optional atom properties (with the potential exception of readable/writeable selectors). This would make it very difficult for type systems to correctly track the types, especially when it may be dynamic which atom/selector is actually being used in the hook.

It's perfectly reasonable to export a set of hooks/callbacks for your data layer to abstract actions for working with your Recoil state. This helps you enforce type-safety for your custom actions.

Also, fyi, for a toggle operation it's best to use an updater form of the setter to ensure you are toggling the current state:

function useTheme() {
  const [theme, setTheme] = useRecoilState(themeState);

  function toggle() {
    setTheme(currentTheme => currentTheme === themePair[0] ? themePair[1] : themePair[0]);
  }

  return [theme, { toggle }];
}

Hello @drarmstr. Thank you for your reply and for the note.

What if the type of the return value of the useRecoilState is still the same? I mean, let's imagine the useRecoilState always returns [state, actions]. And if there are no actions provided in an atom, the second element of the [state, actions] pair will be an object with the only field called set; the analog of the setState.

example

1) without actions

store/theme/index.js

import { atom, useRecoilState } from 'recoil';

import { themePair } from 'config';

const themeState = atom({
  key: 'themeState',
  default: 'dark',

  effects: [
    ({ setSelf,  }) => {
      localStorage.getItem('theme-mode') && setSelf(localStorage.getItem('theme-mode')); // the initial value

      onSet(value => localStorage.setItem('theme-mode', value));
    },
  ],
});

export default themeState;

SomeComponent.js

import { useRecoilState } from 'recoil';
import { themeState } from 'store/theme';

function SomeComponent() {
  const [theme, actions] = useRecoilState(themeState);

  console.log(actions); // { set }
  // in this case `actions.set` is the same as `setState` (from `[state, setState]` pair)
  // but I think, also, it worths to consider an empty object version
  // I mean, if there are no actions provided in the `atom` definition the result of
  // this `console.log` can be an empty object

  return ... ;
}

2) with actions

store/theme/index.js

import { atom, useRecoilState } from 'recoil';

import { themePair } from 'config';

const themeState = atom({
  key: 'themeState',
  default: 'dark',

  effects: [
    ({ setSelf,  }) => {
      localStorage.getItem('theme-mode') && setSelf(localStorage.getItem('theme-mode')); // the initial value

      onSet(value => localStorage.setItem('theme-mode', value));
    },
  ],
  actions: {
    toggle({ set }) {
       set(value => value === themePair[0] ? themePair[1] : themePair[0]);
    },
  },
});

export default themeState;

SomeComponent.js

import { useRecoilState } from 'recoil';
import { themeState } from 'store/theme';

function SomeComponent() {
  const [theme, actions] = useRecoilState(themeState);

  console.log(actions); // { toggle }

  return ... ;
}

@drarmstr what do you think about this version? And also I would like to see some real-life examples of Recoil app structure, like @BenjaBobs' example. So, I will kindly ask you to reopen it for a while, if it's possible. Thank you! :)

The function signature would still be dynamic in that case. While it might always return an object containing a set of functions, the parameters and return values for those function actions would not be known for type checking with TypeScript or Flow.

@BenjaBobs example is really a duplicate of #422. It's a reasonable workaround for now. Though, this is a recurring request so we are discussing how to more elegantly support this pattern.

I would like to revive this issue because I think I have a use case that warrants it.

I have some state on my server, and I want to use it on my client. For this I made an abstraction atomExternal with the intention of using Recoil as a bridge between my clientside data and my serverside data.

import { atom, AtomOptions, RecoilState, selector } from 'recoil';

import { RecoilSet } from '../../RecoilUtils';
import { GetRecoilValue, SetRecoilState } from './recoil-types';

export type ExternalRecoilState<TData> = RecoilState<TData> & {
  invalidate: () => void;
};

type AtomExternalOptions<TData> = AtomOptions<TData> & {
  debug?: boolean;
  get: (opts: { get: GetRecoilValue }) => Promise<TData>;
  set: (opts: { get: GetRecoilValue }, value: TData) => Promise<TData>;
  onSetSuccess?: (
    opts: {
      get: GetRecoilValue;
      set: SetRecoilState;
    },
    finalValue: TData
  ) => void | Promise<void>;
  onSetFailure?: (
    opts: {
      get: GetRecoilValue;
      set: SetRecoilState;
    },
    finalValue: TData,
    error: any
  ) => void | Promise<void>;
};

export function atomExternal<TData>(options: AtomExternalOptions<TData>) {
  let initial = options.default;

  const _dataAtom = atom({
    key: options.key + '_atom',
    default: initial,
  });

  // https://github.com/facebookexperimental/Recoil/issues/422
  const _invalidatorAtom = atom({
    key: options.key + '_invalidator',
    default: 0,
  });

  const _externalSelector = selector<TData>({
    key: options.key + '_graph',
    get: async ({ get }) => {
      get(_invalidatorAtom);

      let data = get(_dataAtom);

      if (data === initial) {
        if (options.debug)
          console.log(`[${options.key}] No initial data, fetching...`);

        data = await options.get({ get });

        RecoilSet(_dataAtom, data);
      }

      return data;
    },
    set: async ({ set, get }, newValue) => {
      const oldValue = get(_dataAtom);

      if (options.debug)
        console.log(`[${options.key}] Setting optimistic value`, {
          optimistic: newValue,
          original: oldValue,
        });

      RecoilSet(_dataAtom, newValue as TData);

      try {
        const result = await options.set({ get }, newValue as TData);

        if (options.debug)
          console.log(`[${options.key}] Success! Setting real value`, {
            optimistic: newValue,
            original: oldValue,
            actual: result,
          });

        RecoilSet(_dataAtom, result);
      } catch (error) {
        if (options.debug)
          console.log(`[${options.key}] Failure! Setting old value`, {
            error: error,
            original: oldValue,
            optimistic: newValue,
          });

        RecoilSet(_dataAtom, oldValue);
      }
    },
  }) as ExternalRecoilState<TData>;

  _externalSelector.invalidate = () => RecoilSet(_invalidatorAtom, x => x + 1);

  return _externalSelector;
}

In short, it uses an atom to store the current clientside state, and a selector to get the state (with initial fetch if the data has not been fetched yet).
The setter then optimistically stores the new value in the data atom, and when the fetch resolves, corrects the state.
This provides optimistic UI changes, which are nice, but then I ran into having to wait for the request to finish, because I need to ensure the data is persisted before I do some other action.
I'd like to provide a way to access both an optimistic set and a non-optimistic set, and the actions proposal could maybe fulfill that need, especially if they could return a Promise<void> that you could then simply await if needed.

If this is not achievable due to the type system, then maybe there's a different architectural approach than will also allow multiple setters and awaiting them?

Hi @BenjaBobs.

For bi-directional (and not only) synchronization, I reckon, the best way is using recoil atom effects.

This is how I would handle your case:

import React from "react";

import { atom, useRecoilState } from "recoil";

const exampleState = atom({
  key: "exampleState",
  default: {
    data: 1, // just a number
    isOptimistic: false // it means data has already persisted
  },

  effects: [
    ({ setSelf, onSet }) => {
      onSet(async (value) => {
        // this function will be called after each state update
        // so we can organize the synchronization here
        if (value.isOptimistic) {
          const result = await api.postSomething(value.data);
          setSelf({
            data: result,
            isOptimistic: false // it means data has already persisted
          });
        }
      });
    }
  ]
});

function App() {
  const [value, setValue] = useRecoilState(exampleState);

  function handleSet() {
    setValue({
      data: Math.random(),
      isOptimistic: true // it means data hasn't persisted yet
    });
  }

  return (
    <div>
      <div>{value.data}</div>
      <div>
        <span>{value.isOptimistic + ""}</span>
        <span>
          -{" "}
          {value.isOptimistic
            ? "it means data hasn't persisted yet"
            : "it means data has already persisted"}
        </span>
      </div>
      <button onClick={handleSet}>Set a new value (a random number)</button>
    </div>
  );
}

// fake api
const api = {
  postSomething(value) {
    return new Promise((res) => setTimeout(() => res(value), 2000));
  }
};

export default App;

Here you can play with it.

So, if we are talking about only data synchronization, at least, in this case, actions are not playing a big role. But... actions could have helped us avoid this part of the code:

...
  function handleSet() {
    setValue({
      data: Math.random(),
      isOptimistic: true
    });
  }
...

And it could look like this:

import React from "react";

import { atom, useRecoilState } from "recoil";

const exampleState = atom({
  key: "exampleState",
  default: {
    data: 1, // just a number
    isOptimistic: false // it means data has already persisted
  },

  effects: [
    ({ setSelf, onSet }) => {
      onSet(async (value) => {
        // this function will be called after each state update
        // so we can organize the synchronization here
        if (value.isOptimistic) {
          const result = await api.postSomething(value.data);
          setSelf({
            data: result,
            isOptimistic: false // it means data has already persisted
          });
        }
      });
    }
  ],

  actions: {
    set({ setSelf }, data) {
      setSelf({
        data,
        isOptimistic: true // now, nobody can miss this flag
      });
    }
  },
});

function App() {
  const [value, actions] = useRecoilState(exampleState);

  function handleSet() {
    actions.set(Math.random());
  }

  return (
    <div>
      <div>{value.data}</div>
      <div>
        <span>{value.isOptimistic + ""}</span>
        <span>
          -{" "}
          {value.isOptimistic
            ? "it means data hasn't persisted yet"
            : "it means data has already persisted"}
        </span>
      </div>
      <button onClick={handleSet}>Set new value (a random number)</button>
    </div>
  );
}

// fake api
const api = {
  postSomething(value) {
    return new Promise((res) => setTimeout(() => res(value), 2000));
  }
};

export default App;

I would also like to revive the issue to discuss more such kind of problems and see probable solutions/workaround despite the supposed issue related to types or even impossible integration of actions, but @drarmstr doesn't want to do that 馃槃

We do want to avoid complicating the API unless there is a really compelling case. We are already nervous about the complexity of Atom Effects and thus trying to be cautious before really exposing it. The philosophy is that it is preferable to have a simpler core API that allows composability, rather than adding complexity to the core API if it doesn't actually enable some functionality that can't be achieved otherwise or dramatically simplify the usage. In the latest example, enforcing the extra check during a set can be achieved with a wrapper selector or hook. It's perfectly reasonable to make abstractions like atomExternal to help capture useful patterns.

Also note that the selector set should not be async. The current reasoning for this is that by making it synchronous it helps provide an expectation that the effects of the set can then be observed by subsequent sets that use the updater form to get the current state. useRecoilCallback() is available for situations where you want multiple or async sets. Though, we may reconsider this based on ongoing feedback.

I can definitely understand wanting to keep the core api as simple as possible. Unfortunately without being able to await I had to split my logic from the recoil state. I guess it still works, but doesn't look as neat as I had hoped. :P

Maybe atom actions can be achieved through abstraction as well. Also great idea for handling the optimistic updates @suren-atoyan, I'll probably see if I can refactor my code to use same principle.

@drarmstr I really understand your point of view and it's completely okay. I still stick to the view that it's not complicating the API and both effects and actions are for clean and meaningful code. And even if actions are only for readability and clean code, and technically it's possible to achieve them through abstraction (which is not an argument for me in this case, but) the atom effects, I think, is an irreplaceable feature. For example, before atom effects I couldn't manage to organize bi-directional synchronization in a "nice" way. And the bi-directional synchronization is not the only advantage for effects. Maybe the current design of the effects isn't the best one (maybe), but the idea of effects, in my opinion, should exist and it'is out of the question for me. But again, I understand your point of view, so, we can "double close" this issue 馃槃

@BenjaBobs yes, definitely "atom actions" can be achieved through abstraction. I've already shown an example in my first message (see useTheme hook). It doesn't cover my needs, but technically it's doable.

About optimistic updates: please note that I covered only the case of optimistic updates, you have also an invalidation problem, which I didn't touch in my example.

Jotai's answer to actions is the reducerAtom: https://github.com/pmndrs/jotai/blob/master/docs/utils.md#usereduceratom

A reducer atom layered on top sounds like a nice abstraction. @BenjaBobs, feel free to share or contribute if you have any cool helpers!

I'm still chewing on the details, but I will when if I come up with something that I feel can be useful in a general sense without being too opinionated/locked. Maybe you should have a section in the documentation where you collect common partterns/ways of using Recoil. I feel especially organizing one's data in a scaleable way both regarding discovery and cluttering is something that could use a good thinking about, and maybe a guide.

Hmmm, reducerAtom is an interesting approach.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

robsoncezario picture robsoncezario  路  3Comments

karevn picture karevn  路  3Comments

julienJean99 picture julienJean99  路  3Comments

adamkleingit picture adamkleingit  路  4Comments

Sawtaytoes picture Sawtaytoes  路  4Comments