はじめに
開発中のプロダクトでこれまでuseContextを使用していたのですが
以下のデメリットからRecoilへの移行を行ったので使い方とテストの書き方をメモがてら書きます。
useContextのデメリット
・パフォーマンス
→useContextの値を変更した場合context.providerでラップされているすべての
コンポーネントが再レンダリングされる
アプリケーションの規模が大きくなるほどこの問題は顕著になってきます。
・provider多すぎ問題
→以下のようにProviderが多すぎてだんだんコードが読みにくくなって来ます
return (
<ThemeContext.Provider value={theme}>
<UserContext.Provider value={user}>
<LanguageContext.Provider value={language}>
<SettingsContext.Provider value={settings}>
<AuthContext.Provider value={auth}>
<MyComponent />
</AuthProvider>
</SettingsProvider>
</LanguageProvider>
</UserProvider>
</ThemeProvider>
);
};
※それぞれuseMemoによるメモ化、複数のProviderでを含めたコンポーネントを定義する
などで対策は可能ですがそんなこと気にするのはめんどくさいです。
そこでRecoilに移行することになりました。
Recoilのメリット
Recoilのメリットはたくさんあるみたいですが上記2点に関してはuseContextより優れていそうです
・パフォーマンス
→atom単位で必要なものだけを購読しているので関係ないところの再レンダリングは発生しません。
・provider多すぎ問題
→RecoilrootでラップするだけでOKです
return (
<RecoilRoot>
<MyComponent />
</RecoilRoot>
);
};
state管理の方法
atom
管理したいステートをatomで定義します
useContextでのReact.createContextに相当します。
import {atom} from "recoil";
export const textState = atom<string>({
key: 'textState',
default: '',
});
selector
atomに対して決められた計算を行う場合はselectorを使用して定義します。
import { selector} from "recoil";
import {textState} from "./textState";
export const charCountState = selector({
key: 'charCountState',
get: ({get}) => {
const text = get(textState);
return text.length;
},
});
上記のselectorはtextStateの文字列を返します
textStateの値が変更されるたびにこのselectorは再計算されます。
そして計算結果が変わった場合のみ(このサンプルでは文字数が変わった場合)
このselecotrを購読しているコンポーネントで再レンダリングが発生します。
計算結果が変わらない場合、再レンダリングが発生しないのは嬉しいです。
keyの管理方法
上記のatom,selectorともにkeyは重複が許されません。
atomで使ってるkeyとselecotrで使ってるkeyが同じでもダメです。
なのでtypescriptを使っている場合はenumで管理すると重複が許されないので楽です。
export enum RecoilKeys {
TextState = 'textState',
CharCountState = 'charCountState',
}
atomの中では以下のようにkeyを使います
import {atom} from "recoil";
import {RecoilKeys} from "./RecoilKeys";
export const textState = atom<string>({
key: RecoilKeys.TextState,
default: '',
});
stateの使用方法
useRecoilStateに購読したいatom(atomを代入している変数です)を渡します。
keyを渡す訳ではないので注意です。
import {useRecoilState} from "recoil";
import React from "react";
import {textState} from "../atom/textState";
export default function TextInput() {
const [text, setText] = useRecoilState(textState);
const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setText(event.target.value);
setTextList([...textList,event.target.value])
};
return (
<div>
<input type="text" value={text} onChange={onChange}/>
<br/>
Echo: {text}
</div>
);
}
stateの購読のみを行いたい場合
const text = useRecoilValue(textState)
stateの更新のみを行いたい場合
const setText = useSetRecoilState(textState)
更新のみの場合は他のコンポーネントでステートが更新されても
そのコンポーネントの再レンダリングは発生しません。
単体テストの書き方
stateに初期値を仕込みたい場合
test('初期にstateが持っている値レンダーする。', async () => {
const initialText = 'test'
render(
<RecoilRoot initializeState={({set})=>set(textState,initialText)}>
<CharacterCounter repo={stubRepo}/>
</RecoilRoot>
);
expect(screen.getByText('Echo: test')).toBeInTheDocument();
});
RecoilRootのオプションプロパティであるinitializeStateを使って初期値を仕込みます。
initializeStateには状態を初期化する関数を渡します。
引数として{set,get,reset}の3つの関数を受け取れます。
set関数: これはRecoilの状態(atomやselector)に初期値を設定するために使われます。
set関数は2つの引数を取ります。最初の引数は設定したいRecoilの状態で、
2番目の引数はその状態に設定したい値です。
get関数: この関数は、他のRecoilの状態の現在値を読み取るために使われます。
引数は一つだけで取得したいRecoilの状態(atomやselector)
reset関数: この関数はRecoilの状態をそのデフォルト値へリセットするために使われます。
引数は一つだけで取得したいRecoilの状態(atomやselector)
テストの仕込みとして使用するのはsetが多いと思います。
atomのset関数が呼び出されていることを確認したい場合
状態更新用のset関数のspy用にRecoilObserverというコンポーネントを作成します。(名前はなんでもいいです。)
import {useRecoilValue} from "recoil";
import {useEffect, useRef} from "react";
export const RecoilObserver = ({ node, onChange }: any) => {
const value = useRecoilValue(node);
const isMounted = useRef(false);
useEffect(() => {
if (isMounted.current) {
onChange(value);
} else {
isMounted.current = true;
}
}, [onChange, value]);
return null;
};
このコンポーネントではnode(spyしたいatom)とspy関数を渡します。
const value = useRecoilValue(node);
でspy対象のstateを購読して
useEffectで対象の値を依存配列にして対象の値が変更された際にonChangeが呼び出されます。
useEffectは初回レンダリング時も実行されてしまうので初回レンダリング時はスキップするようにしています。
そしてテストの時に以下のようにテスト対象のコンポーネントと一緒にレンダリングしてあげるだけです。
test('set関数を呼んでいることを確認する', async () => {
const onChange = jest.fn();
render(
<RecoilRoot>
<RecoilObserver node={textState} onChange={onChange}/>
<CharacterCounter repo={stubRepo}/>
</RecoilRoot>
);
await userEvent.type(screen.getByRole('textbox'), 'kida')
expect(onChange).toHaveBeenCalledWith('kida');
});
この方法だと状態更新用のset関数の動作を乗っ取ることなくspyを仕込むことができるので便利ですね。
複数のset関数をspyしたい場合はその数だけRecoilObserverを一緒にレンダリングしてあげましょう
最後に
selectorを使うようになったらselectorのテストの書き方も調べて更新します。