10
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Posted at

はじめに

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

この記事ではRecoilの状態管理についてある程度知っていることを前提とするので、Recoil自体がどのようなライブラリであるかを知りたい場合は公式ドキュメントや私が以前書いた記事をみてください。

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

Atom Effects

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

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

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

利用方法

まずはAtom Effectsを定義する方法です。

const user = atom<User>({
  key: 'user',
  default: defaultUser,
});

通常のatomeffectsをキーとした複数のAtom Effectを追加するように定義します。

const user = atom<User>({
  key: 'user',
  default: defaultUser,
  effects: [atomEffect1, atomEffect2],
});

effectsにはReadonlyArray<AtomEffect>という型の値を渡すことができます。AtomEffectは名前の通りAttom Effectの型で、v0.7.7では以下のように定義されています。

// 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は状態に対する操作をパラメータで受け取って、それを利用して副作用の実行や状態に対する操作する関数です。
状態を更新するたびに変化をコンソールに表示したい時は以下のように書きます。

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ごとに数値が変わります。

<RecoilRoot>
  <SampleComponent1 />
  <RecoilRoot>
    <SampleComponent2 />
    <RecoilRoot>
      <SampleComponent3 />
    </RecoilRoot>
    <SampleComponent4 />
  </RecoilRoot>
</RecoilRoot>

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

trigger

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

setSelf

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

effectの実行は状態の初期取得時に実行されます。以下のように定義されていた場合、使う側はdefaultの値0を扱うことはなく、9が初期値であったかように振る舞います(初期レンダリングは0ではなく9で実行されます)。

const num = atom<number>({
  key: 'number',
  default: 0,
  effects: [
    ({ setSelf }) => {
      setSelf(9);
    },
  ],
});

resetSelf

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

onSet

例として紹介したパラメータです。状態が更新されたときに実行する処理を登録する関数です。
この関数の型は以下のようになっています。

(param: (newValue: T, oldValue: T | DefaultValue, isReset: boolean) => void) => void,

更新する値newValueと更新前の値oldValue、更新がリセットか否かを表すフラグisResetをパラメータとして受け取って記述できます。

getPromise

他のRecoilの状態を利用する際に使う関数です。引数に利用したい状態を渡すと、状態の持つ値が非同期で返却されます。

({ getPromise }) => {
  getPromise(otherState).then((state) => {
     console.log(state);
  });
},

getLoadable

getPromiseと同じく、他のRecoilの状態を利用する際に使う関数です。getPromiseと違う点は非同期な値が返ってくるのではなく、Lodableクラスが値として返ってきます。
具体的には3つの状況で返す値が変わります。

  • 状態の値が確定したとき
  • 読み込んでいるとき
  • エラーが発生したとき

Stateを状態の型としたとき、以下のような値が返されます。

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の値を参照するカウンターを作ります。完成したものは以下のように動きます(例えばリロードしても前回の値を保持します。)。

まずはatomを用いた普通のカウンターを作ります。

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の定義に以下のようなコードを追加します。

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を使った副作用を定義しています。

const savedNum = Number(localStorage.getItem('counter'));
setSelf(savedNum || 0);

↑の部分は状態が初めて呼び出された時にlocalStorageから値を取得して状態を更新しています。これによって状態の初期値がlocalStorageのもつ値であるような振る舞いをします。

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

↑の部分は状態が更新されたときに更新した値をlocalStorageに保存しています。これによってlocalStorageの値がatomに同期する仕組みを作ることができます。状態をリセットするときはlocalStorageの値をセットするのではなく削除するようにしています。

最終的にできたコードは以下のようになっています。

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で書くと以下のようになります。

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

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

さいごに

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

10
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?