はじめに
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,
});
通常のatom
にeffects
をキーとした複数の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、SampleComponent2
とSampleComponent4
は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
の値を直接atom
のdefault
に指定させました。また、localStorage
の値も更新をuseEffect
で行いました。
Atom Effectを使った時に比べて、状態の振る舞いを確認するためにatom
を定義した部分とコンポーネント関数を往復しなければならないので読みづらいように感じます(実際はatom
の定義を行うコードはコンポーネントと別ファイルにあるのでここにある例よりさらに読みにくいです)。
このコードは先ほどのAtom Effectと一見同じ挙動をしそうですが、useResetRecoilState
を用いたデータの初期化時に異なる挙動をします。
useEffect
を用いたときは最初に初期値として設定したlocalStorage
の値になりますが、Atom Effectでは0
になります。useResetRecoilState
はdefault
に指定した値に戻す処理であるからです。
Atom Effectのコードでは状態の初期値とlocalStorage
との同期を別にしているのに対して、useEffect
のコードでは初期値とlocalStorage
の同期を同時に行っているので、このような挙動になります。
useEffect
でこの分離をしようとすると簡単にはlocalStorage
の同期を行うためのロジックを記述することができません。これが先ほど話したuseEffect
にはできないが、AtomEffectにできる初期化に関する処理です。
さいごに
Atom Effectについて説明しました。一見複雑そうですが、これを用いて状態を記述することでよりシンプルかつ綺麗に書けます。そのためatom
に関する副作用はuseEffect
ではなく、Atom Effectを用いてみてはいかがでしょうか。