Recoilの状態に対する副作用をAtom Effectで支配する

# はじめに
[Recoil](https://recoiljs.org/)はReactの状態管理ライブラリの1つです。製品版にはなっていませんが、開発元がReactと同じMetaであることによる信頼、コンセプトの筋の良さから多くのユーザーに利用されているライブラリです。

この記事ではRecoilの状態管理についてある程度知っていることを前提とするので、Recoil自体がどのようなライブラリであるかを知りたい場合は[公式ドキュメント](https://recoiljs.org/)や私が[以前書いた記事](https://qiita.com/KokiSakano/items/99bfc87d954c3c515ce4)をみてください。

この記事では、`atom`に対する副作用を扱う`Atom Effects`について紹介します。

# Atom Effects
Atom Effectsは`atom`に対する副作用を管理して`atom`の更新や初期化を行うような機能です。

- 外部データとの同期
- 非同期データの永続化
- 状態の変化をログ化

などを行うときに活用されます。

## 利用方法
まずはAtom Effectsを定義する方法です。
```ts
const user = atom<User>({
  key: 'user',
  default: defaultUser,
});
```
通常の`atom`に`effects`をキーとした複数のAtom Effectを追加するように定義します。
```ts
const user = atom<User>({
  key: 'user',
  default: defaultUser,
  effects: [atomEffect1, atomEffect2],
});
```
`effects`には`ReadonlyArray<AtomEffect>`という型の値を渡すことができます。`AtomEffect`は名前の通りAttom Effectの型で、`v0.7.7`では以下のように定義されています。
```ts
// Effect is called the first time a node is used with a <RecoilRoot>
type AtomEffect<T> = (param: {
  node: RecoilState<T>,
  storeID: StoreID,
  parentStoreID_UNSTABLE?: StoreID,
  trigger: 'set' | 'get',

  // Call synchronously to initialize value or async to change it later
  setSelf: (param:
    | T
    | DefaultValue
    | Promise<T | DefaultValue>
    | WrappedValue<T>
    | ((param: T | DefaultValue) => T | DefaultValue | WrappedValue<T>),
  ) => void,
  resetSelf: () => void,

  // Subscribe callbacks to events.
  // Atom effect observers are called before global transaction observers
  onSet: (
    param: (newValue: T, oldValue: T | DefaultValue, isReset: boolean) => void,
  ) => void,

  // Accessors to read other atoms/selectors
  getPromise: <S>(recoilValue: RecoilValue<S>) => Promise<S>,
  getLoadable: <S>(recoilValue: RecoilValue<S>) => Loadable<S>,
  getInfo_UNSTABLE: <S>(recoilValue: RecoilValue<S>) => RecoilStateInfo<S>,
 }) => void | (() => void);
```
型からわかるように、`AtomEffect`は状態に対する操作をパラメータで受け取って、それを利用して副作用の実行や状態に対する操作する関数です。
状態を更新するたびに変化をコンソールに表示したい時は以下のように書きます。
```ts
const user = atom<string>({
  key: 'name',
  default: '',
  effects: [
    ({ onSet }) => {
      onSet: (newValue, oldValue) => {
        console.log(`${oldValue}から${newValue}に更新しました。`);
      };
    },
  ],
});
```
パラメータから値を更新するときに発火させたい副作用を定義する`onSet`を受け取って、それを利用して状態を更新するたびにコンソールに変化を出力します。`effects`に書かれたコードは状態を利用する前に発火します(状態を扱う側ではAtom Effectの実行後の状態が渡されます)。

次に、Atom Effectで状態に関するどのようなパラメータを扱えるか見ていきます。
### node
nodeは定義した`atom`自身を返します。

### storeId
`useRecoilStoreId`が返す値と同じ値を返します。それを呼び出すコンポーネントと一番近い親コンポーネントの`RecoilRoot`ごとに数値が変わります。

```tsx
<RecoilRoot>
  <SampleComponent1 />
  <RecoilRoot>
    <SampleComponent2 />
    <RecoilRoot>
      <SampleComponent3 />
    </RecoilRoot>
    <SampleComponent4 />
  </RecoilRoot>
</RecoilRoot>
```
このように定義されたツリーだと、`SampleComponent1`で呼び出したときは1、`SampleComponent2`と`SampleComponent4`は2、`SampleComponent3`は3が返ってきます(数値は例です)。
これは特定のRecoilRootでのみ実行させたい副作用を定義するときに活用できます。

### trigger
状態の初期化を行った時の処理を返します。状態を取得したときは`'get'`、更新を行ったときは`'set'`になります。
状態の初期化を行う操作で処理を変えるときに使われます。

### setSelf
自身の状態を更新する関数です。値を渡したり、以前の値をもとに計算させる関数を渡したり、Reactにおける一般的な状態の更新方法と同じような更新処理を渡します。

`effect`の実行は状態の初期取得時に実行されます。以下のように定義されていた場合、使う側は`default`の値`0`を扱うことはなく、`9`が初期値であったかように振る舞います(初期レンダリングは`0`ではなく`9`で実行されます)。
```ts
const num = atom<number>({
  key: 'number',
  default: 0,
  effects: [
    ({ setSelf }) => {
      setSelf(9);
    },
  ],
});
```

### resetSelf
自身を初期値にリセットする関数です。実行されると状態は`default`で定義された値になります。
`setSelf`の例での状態に対して実行すると`9`ではなく`0`、つまり`default`に定義された値になります。

### onSet
例として紹介したパラメータです。状態が更新されたときに実行する処理を登録する関数です。
この関数の型は以下のようになっています。
```ts
(param: (newValue: T, oldValue: T | DefaultValue, isReset: boolean) => void) => void,
```
更新する値`newValue`と更新前の値`oldValue`、更新がリセットか否かを表すフラグ`isReset`をパラメータとして受け取って記述できます。

### getPromise
他のRecoilの状態を利用する際に使う関数です。引数に利用したい状態を渡すと、状態の持つ値が非同期で返却されます。
```ts
({ getPromise }) => {
  getPromise(otherState).then((state) => {
     console.log(state);
  });
},
```

### getLoadable
`getPromise`と同じく、他のRecoilの状態を利用する際に使う関数です。`getPromise`と違う点は非同期な値が返ってくるのではなく、`Lodable`クラスが値として返ってきます。
具体的には3つの状況で返す値が変わります。
- 状態の値が確定したとき
- 読み込んでいるとき
- エラーが発生したとき

`State`を状態の型としたとき、以下のような値が返されます。
```ts
type Loadable = {
  state: 'hasValue',
  contents: State,
} | {
  state: 'loading',
  contents: Promise<State>,
} | {
  state: 'hasError',
  contents: any,
}
```
実際にはこれ以外のプロパティをいくつか持っているので注意してください。


### 返り値
返り値は`void`または`void`を返す関数`() => void`です。クリーンアップするためには`useEffect`のように返り値にクリーンアップ関数を渡す必要があります。

## useEffectとの違い
Reactで副作用を扱う多くのケースでは`useEffect`を用います。そして、Atom Effectで定義するほとんどのことは`useEffect`を用いても実装できます。
それでもAtom Effectを使う主なモチベーションは、特にReactの外で行われる`atom`の定義と同時に行える点にあります。
`useEffect`はあくまでコンポーネントに対する副作用を扱うための関数なので状態に対する副作用をAtom Effectで定義することで、コンポーネントと状態についてのコードを綺麗に分離できます。これによってDOMに対する操作はReact、状態に対する操作はRecoilのようにそれぞれのライブラリに役割を徹底させることができます。

そうでなくても、初期化に関する処理や、SSRにも利用できる点などはAtom Effectにできますが`useEffect`にはできないので機能面であっても差別化ができます。

## `localStorage`の永続化
最後に、Atom Effectを使って`localStorage`の値を参照するカウンターを作ります。完成したものは以下のように動きます(例えばリロードしても前回の値を保持します。)。

https://codesandbox.io/embed/clever-elbakyan-e3x018?fontsize=14&hidenavigation=1&theme=dark

まずは`atom`を用いた普通のカウンターを作ります。
```tsx
const countAtom = atom<number>({
  key: 'count',
  default: 0,
});

const Counter: FC = () => {
  const [count, setCount] = useRecoilState(countAtom);

  return (
    <button onClick={() => setCount(count => count + 1)}>
      {count}
    </button>
  );
};
```
ここにAtom Effectを与えて`localStorage`の値と同期させていきます。`atom`の定義に以下のようなコードを追加します。
```ts
effects: [
  ({ setSelf, onSet }) => {
    const savedNum = Number(localStorage.getItem('counter'));
    setSelf(savedNum || 0);

    onSet((newValue, _, isReset) => {
      return isReset
        ? localStorage.removeItem('counter')
        : localStorage.setItem('counter', String(newValue));
    });
  },
]
```
自身の値を更新する`setSelf`と更新時に行う処理を追加する`onSet`を使った副作用を定義しています。

```ts
const savedNum = Number(localStorage.getItem('counter'));
setSelf(savedNum || 0);
```
↑の部分は状態が初めて呼び出された時に`localStorage`から値を取得して状態を更新しています。これによって状態の初期値が`localStorage`のもつ値であるような振る舞いをします。

```ts
onSet((newValue, _, isReset) => {
  return isReset
    ? localStorage.removeItem('counter')
    : localStorage.setItem('counter', String(newValue));
});
```
↑の部分は状態が更新されたときに更新した値を`localStorage`に保存しています。これによって`localStorage`の値が`atom`に同期する仕組みを作ることができます。状態をリセットするときは`localStorage`の値をセットするのではなく削除するようにしています。

最終的にできたコードは以下のようになっています。
```tsx
const countAtom = atom<number>({
  key: 'count',
  default: 0,
  effects: [
    ({ setSelf, onSet }) => {
      const savedNum = Number(localStorage.getItem('counter'));
      setSelf(savedNum || 0);

      onSet((newValue, _, isReset) => {
        return isReset
          ? localStorage.removeItem('counter')
          : localStorage.setItem('counter', String(newValue));
      });
    },
  ],
});

const Counter: FC = () => {
  const [count, setCount] = useRecoilState(countAtom);

  return (
    <button onClick={() => setCount(count => count + 1)}>
      {count}
    </button>
  );
};
```
かなり簡単にかけました。これと似たようなコードを`useEffect`で書くと以下のようになります。
```tsx
const countAtom = atom<number>({
  key: "count",
  default: Number(localStorage.getItem("counter")) || 0
});

const Counter: FC = () => {
  const [count, setCount] = useRecoilState(countAtom);

  useEffect(() => {
    localStorage.setItem("counter", String(count));
  }, [count]);

  return (
    <button onClick={() => setCount((count) => count + 1)}>{count}</button>
  );
}

```
この例では初期化を`localStorage`の値を直接`atom`の`default`に指定させました。また、`localStorage`の値も更新を`useEffect`で行いました。
Atom Effectを使った時に比べて、状態の振る舞いを確認するために`atom`を定義した部分とコンポーネント関数を往復しなければならないので読みづらいように感じます(実際は`atom`の定義を行うコードはコンポーネントと別ファイルにあるのでここにある例よりさらに読みにくいです)。
このコードは先ほどのAtom Effectと一見同じ挙動をしそうですが、`useResetRecoilState`を用いたデータの初期化時に異なる挙動をします。

https://codesandbox.io/embed/nervous-tereshkova-t5xebi?fontsize=14&hidenavigation=1&theme=dark

`useEffect`を用いたときは最初に初期値として設定した`localStorage`の値になりますが、Atom Effectでは`0`になります。`useResetRecoilState`は`default`に指定した値に戻す処理であるからです。
Atom Effectのコードでは状態の初期値と`localStorage`との同期を別にしているのに対して、`useEffect`のコードでは初期値と`localStorage`の同期を同時に行っているので、このような挙動になります。
`useEffect`でこの分離をしようとすると簡単には`localStorage`の同期を行うためのロジックを記述することができません。これが先ほど話した`useEffect`にはできないが、AtomEffectにできる初期化に関する処理です。

# さいごに
Atom Effectについて説明しました。一見複雑そうですが、これを用いて状態を記述することでよりシンプルかつ綺麗に書けます。そのため`atom`に関する副作用は`useEffect`ではなく、Atom Effectを用いてみてはいかがでしょうか。