Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Recoil #185

Open
hysryt opened this issue Aug 18, 2021 · 15 comments
Open

Recoil #185

hysryt opened this issue Aug 18, 2021 · 15 comments

Comments

@hysryt
Copy link
Owner

hysryt commented Aug 18, 2021

https://recoiljs.org/

@hysryt
Copy link
Owner Author

hysryt commented Aug 18, 2021

React用のステート管理ライブラリ。
同じような用途のライブラリとしてReduxが有名。(ReduxはReact用途だけではなく汎用的なライブラリ)

2021/08/18現在、まだ試験的な段階でありプロダクトでの使用は避けた方が良い。

Reduxにあった「アクション」と言う概念がないため、予測可能性がReduxと比べて弱い。
そのため、アトムやセレクタを直接エクスポートするのではなく、ステートの更新用途ごとにカスタムフックを作成してそれをエクスポートすると良い。


  • Reduxのような外部ライブラリによる管理よりもReactのビルトイン機能での管理の方が互換性などの面から良い。
  • ただしReactの機能には一部制限がある
    • ステートのリフトアップによって再レンダリングツリーが大きくなる
    • Contextは単一の値しか持てない(コンポーネントごとに値を持つことができない)
    • 上記2点はコード分割の面でも不利
  • Reactらしさを保ちつつ上記を改善するために開発されているのがRecoil

@hysryt
Copy link
Owner Author

hysryt commented Aug 18, 2021

アトム

ステートの単位。
アトムが更新されると、そのアトムをサブスクライブするコンポーネントが再レンダリングされる。
コンポーネントごとのアトムを生成することも可能。
複数のコンポーネントから同一のアトムをサブスクライブすることも可能。

const fontSizeState = atom({
  key: 'fontSizeState',
  default: 14,
});

アトムには一意のキーを設定する必要がある。
2つのアトムが同一のキーを持つとエラーになる。

アトムの値を読み書きするには useReactState フックを使用する。
useState フックに似ている。

function FontButton() {
  const [fontSize, setFontSize] = useRecoilState(fontSizeState);
  return (
    <button onClick={() => setFontSize((size) => size + 1)} style={{fontSize}}>
      Click to Enlarge
    </button>
  );
}

useState フックとの違いは、複数のコンポーネントで値が共通される点。

アトムファミリー

アトムを返す関数を作成する。

atom 関数で作成したアトムはグローバルで一意となる。
アトムファミリーで作成した関数はパラメータごとにごとなるアトムを作成する。

const counterStateFamily = atomFamily({
  key: 'counterState',
  default: 0,
});

const Component1 = () => {
  const counter = useRecoilValue(counterStateFamily(1));
  return <div>{counter}</div>;
};

const Component2 = () => {
  const counter = useRecoilValue(counterStateFamily(2));
  return <div>{counter}</div>;
};

Component1とComponent2は別々のアトムを持つ。

パラメータを元にデフォルト値を設定することも可能。

const myAtomFamily = atomFamily({
  key: ‘MyAtom’,
  default: param => defaultBasedOnParam(param),
});

ステートにもアクセスしたい場合は selectorFamily を併用する。パラメータにもアクセス可能。

const myAtomFamily = atomFamily({
  key: ‘MyAtom’,
  default: selectorFamily({
    key: 'MyAtom/Default',
    get: param => ({get}) => {
      const otherAtomValue = get(otherState);
      return computeDefaultUsingParam(otherAtomValue, param);
    },
  }),
});

パラメータごとにアトムを作成するからアトムファミリー(アトムの集まり)なんだと思われる。

セレクタ

アトムや他のセレクタを基にデータを作成する。
アトムが更新されるとセレクタは再評価される。
セレクタが変更されるとコンポーネントが再レンダリングされる。

Vueでいう computed みたいな感じ?

アトムとセレクタは同じAPIを持つため、コンポーネントからは両方とも同じように扱える。(代替も可能)

const fontSizeLabelState = selector({
  key: 'fontSizeLabelState',
  get: ({get}) => {
    const fontSize = get(fontSizeState);
    const unit = 'px';

    return `${fontSize}${unit}`;
  },
});

get プロパティにデータの加工処理を行う関数を記述する。
引数の get はアトムやセレクタにアクセスできる関数。
get によって依存関係が生まれ、依存先の値が更新されるとこのセレクタも再評価される。

上記の例は一言で言うと、
「fontSizeLabelStateセレクタはfontSizeStateアトムに依存し、アトムの値にpxをつけた値を出力する」
となる。

コンポーネントからセレクタの値を参照するには useRecoilValue フックを使用する。
useRecoilValue フックはアトムやセレクタの値を参照する為のフック。
useRecoilState がアトムの読み書きを行うのに対し、useRecoilValue はアトムとセレクタの読み込みのみ行う。

function FontButton() {
  const [fontSize, setFontSize] = useRecoilState(fontSizeState);
  const fontSizeLabel = useRecoilValue(fontSizeLabelState);

  return (
    <>
      <div>Current font size: {fontSizeLabel}</div>

      <button onClick={() => setFontSize(fontSize + 1)} style={{fontSize}}>
        Click to Enlarge
      </button>
    </>
  );
}

セレクタファミリー

パラメータを渡してセレクタを返す関数を作成する。
通常のセレクタはアトムや他のセレクタのみを入力として扱うが、アトムやセレクタ以外のパラメータを渡したい時には selectorFamily を使用する。

const userNameQuery = selectorFamily({
  key: 'UserName',
  get: userID => async () => {
    const response = await myDBQuery({userID});
    if (response.error) {
      throw response.error;
    }
    return response.name;
  },
});
  const userName = useRecoilValue(userNameQuery(userID));

アトムやセレクタの変化だけでなく、パラメータの変化によってもセレクタが再評価される。

パラメータごとにセレクタを作成するからセレクタファミリー(セレクタの集まり)なんだと思われる。

@hysryt
Copy link
Owner Author

hysryt commented Aug 19, 2021

Getting Started

npx create-react-app my-app
cd my-app
npm install reacoil

ES5にトランスパイルされていないため必要な場合は別途Babelでのトランスパイルが必要。
ただし公式にサポートしているわけでないらしい。

Recoilを使用したサンプル
https://github.com/hysryt/Recoil-Sample

CDN

<script src="https://cdn.jsdelivr.net/npm/[email protected]/umd/recoil.min.js"></script>

@hysryt
Copy link
Owner Author

hysryt commented Aug 20, 2021

RecoilRoot

RecoilはRecoilRootコンポーネント内でのみ使用できる。

import React from 'react';
import { RecoilRoot } from 'recoil';

function App() {
  return (
    <RecoilRoot>
      <div>Hello</div>
    </RecoilRoot>
  );
}

入れ子にすることも可能。
入れ子にした場合、内側のRecoilRootは外側のRecoilRootを覆い隠す。(override propで外側のRecoilRootをルートにすることも可能)

@hysryt
Copy link
Owner Author

hysryt commented Aug 22, 2021

非同期データクエリ

セレクタの get 関数は非同期にすることが可能。
ただしセレクタは冪等(入力が同じであれば常に出力も同じこと)であるべきなので、更新されるデータベースから値を取得するべきではない。
読み込み専用のデータベースから値を取得するのは良い。
更新されるデータベースなど、可変なデータの取得の場合は Query Refresh を使用する。

const currentUserNameQuery = selector({
  key: 'CurrentUserName',
  get: async ({get}) => {
    const response = await myDBQuery({
      userID: get(currentUserIDState),
    });
    return response.name;
  },
});

function CurrentUserInfo() {
  const userName = useRecoilValue(currentUserNameQuery);
  return <div>{userName}</div>;
}

入力ごとに出力がキャッシュされるため、入力が同じ場合は myDBQuery は実行されない。

使う側(つまり useRecoilValue を実行する側)は同期的か非同期かを気にする必要がなく、同じAPIでアクセスできる。

非同期の get 関数を使用する場合は React.Suspense でPromise解決前に表示するJSXを指定する必要がある。

function MyApp() {
  return (
    <RecoilRoot>
      <React.Suspense fallback={<div>Loading...</div>}>
        <CurrentUserInfo />
      </React.Suspense>
    </RecoilRoot>
  );
}

React.Suspense ではなく useRecoilValueLoadable を使う方法もある。

function UserInfo({userID}) {
  const userNameLoadable = useRecoilValueLoadable(userNameQuery(userID));
  switch (userNameLoadable.state) {
    case 'hasValue':
      return <div>{userNameLoadable.contents}</div>;
    case 'loading':
      return <div>Loading...</div>;
    case 'hasError':
      throw userNameLoadable.contents;
  }
}

エラーハンドリング

Promiseのrejectはエラーハンドリング用のコンポーネントを作ってキャッチする。
詳しくは以下参照
https://reactjs.org/docs/error-boundaries.html

同時並行リクエスト

複数の非同期処理を同時に評価したい場合は waitForAll というヘルパー関数を使用する。

const friendsInfoQuery = selector({
  key: 'FriendsInfoQuery',
  get: ({get}) => {
    const {friendList} = get(currentUserInfoQuery);
    return friendList.map(friendID => get(userInfoQuery(friendID)));
  },
});

上記の例では get はシリアルに一つずつ実行される。
同時に実行したい場合は以下のようにする。

const friendsInfoQuery = selector({
  key: 'FriendsInfoQuery',
  get: ({get}) => {
    const {friendList} = get(currentUserInfoQuery);
    const friends = get(waitForAll(
      friendList.map(friendID => userInfoQuery(friendID))
    ));
    return friends;
  },
});

waitForAll が全ての非同期処理が終わるのを待ってから値を返すのに対し、処理が終わったものから値を返していくものは waitForNone を使用する。

const friendsInfoQuery = selector({
  key: 'FriendsInfoQuery',
  get: ({get}) => {
    const {friendList} = get(currentUserInfoQuery);
    const friendLoadables = get(waitForNone(
      friendList.map(friendID => userInfoQuery(friendID))
    ));
    return friendLoadables
      .filter(({state}) => state === 'hasValue')
      .map(({contents}) => contents);
  },
});

Query Refresh

可変データを非同期で取得する方法。

リクエストID

セレクタの入力にリクエストIDを追加することで、セレクタは冪等でなければならないルールを守りつつ可変データの取得ができる。

const requestIdState = atom({
  key: 'requestIdState',
  default: 0,
});

const newsFeedQuery = selector({
  key: 'newsFeedQuery',
  get: async ({get}) => {
    const requestId = get(requestIdState);
    const newsFeed = await myDbAccess();
    return newsFeed;
  }
})

function App() {
  const setRequestId = useSetRecoilState(requestIdState);
  const newsFeed = useRecoilValue(newsFeedQuery);

  const onClick = () => setRequestId(old => old + 1);

  return (
      <div>
        {newsFeed}
        <button onClick={onClick}>更新</button>
      </div>
  );
}

アトムを使用する

単純にアトムを更新する方法。

@hysryt
Copy link
Owner Author

hysryt commented Aug 23, 2021

useRecoilCallback

useRecoilCallback(callback, deps)

ReactのuseCallbackのRecoil版。
コールバックからRecoilのステートにアクセスできる。
アクセスできるのはそのコールバックが実行された時点のスナップショット。

callback の引数

第一引数として受け取るオブジェクトには以下が含まれている。

  • snapshot - コールバックが呼び出された時点のスナップショット。
  • gotoSnapshot - 関数。スナップショットを与えることで、そのスナップショットをグローバルステートに反映する。
  • set - 関数。アトムやセレクタの値をセットする。useStateで取得した set*** 関数のように、値または値を更新する関数を引数として渡す。
  • reset - 関数。アトムやセレクタの値をデフォルトに戻す。
  • transact_UNSTABLE - 関数。トランザクションを実行する。useRecoilTransaction_UNSTABLE()関数を参照。

@hysryt
Copy link
Owner Author

hysryt commented Aug 23, 2021

スナップショット

アトムやセレクタで形成されたデータグラフのある時点でのスナップショット。不変。
スナップショットの内容はRecoilRootごとに別々?
RecoilRootを入れ子にしたら内側のスナップショットと外側のスナップショットで別々の値になった。

スナップショットの取得

useRecoilCallback(callback, deps)

スナップショットにアクセスできる関数を作成する。

const callback = useRecoilCallback(function({snapshot}) {  // コールバック関数のラッパー。snapshotを提供する。
  return function() {  // コールバック関数
    console.log(snapshot);  // スナップショットにアクセスできる
  };
});

引数として与えた callback 関数は、その引数としてcallback を実行した時点の読み取り専用のスナップショットを受け取る。
callback 関数は戻り値として関数を返す。

戻り値の関数の呼び出しをトリガーとしてスナップショットの取得を行う。
対して useRecoilSnapshot はコンポーネントのレンダリングをトリガーとしてスナップショットの取得を行う(サブスクライブ)。

例えばボタンを押すたびにネットアクセスを行いAPIからデータを取得するような場合、useRecoilValueやuseRecoilSnapshotでは実装できない。(ただのコールバック内からはuseRecoilValueやuseRecoilSnapshotは仕様上使えない)
そのような場合にuseRecoilCallbackを使用する。

何かのイベントをトリガーとするときなど、任意のタイミングでステートにアクセスしたい場合に useRecoilCallback を使用する、と覚えると良さそう。

以下はNG。useRecoilValueはアロー関数式の中では使用できない。(フックはコンポーネント直下の処理の中でしか使用できない)

const [counter, setCounter] = useState(0);
const onClick = () => {
  const amount = useRecoilValue(amountState);
  setCounter(old => old + amount);
}

以下はOK。

const [counter, setCounter] = useState(0);
const onClick = useRecoilCallback(({snapshot}) => () => {
  const amount = await snapshot.getPromise(amountState);
  setCounter(old => old + amount);
});

引数の callback は関数を返す関数であることにも注意。
以下はNG。(スナップショットを受け取った関数に処理を書いている)

const onClick = useRecoilCallback(({snapshot}) => {
  const amount = await snapshot.getPromise(amountState);
  setCounter(old => old + amount);
});

以下が正しい。(スナップショットを受け取った関数は関数を返し、その関数の中に処理を書いている)

const onClick = useRecoilCallback(({snapshot}) => () =>  {
  const amount = await snapshot.getPromise(amountState);
  setCounter(old => old + amount);
});

useRecoilSnapshot()

同期的にスナップショットを取得する。
このフックを呼び出したコンポーネントはステートの全ての変更をサブスクライブする。
ステートが変更されるたびにコンポーネントが再レンダリングされるため、使用には注意が必要。(今後のアップデートで改善される可能性あり)
以下はスナップショットの全ての内容をコンソールに出力する例。

const snapshot = useRecoilSnapshot();
for(const node of snapshot.getNodes_UNSTABLE()) {
  console.log(snapshot.getLoadable(node));
}

useRecoilTransactionObserver_UNSTABLE()

ステートの変更をサブスクライブし、変更がある度にスナップショットを取得する。

スナップショットの生成

snapshot_UNSTABLE()

スナップショットを生成する。
Recoil外でも生成できるため、セレクタのテストに役立つ。

スナップショットから値を読み込む

getLoadable

ステートをLoadableとして取得する

getPromise

非同期セレクタの値を取得する

スナップショットから新しいスナップショットを生成する

map

スナップショットをステートに反映する

useGotoRecoilSnapshot()

@hysryt
Copy link
Owner Author

hysryt commented Aug 30, 2021

アトムエフェクト

アトムに副作用を追加する試験段階のAPI。
アトムの初期化時に実行される。

アトムは <RecoilRoot> 内で初めて使用された時に初期化される。
ただし未使用でクリーンアップされた時(どういう意味?)は再度初期化されることもある。

アトムファミリーにアトムエフェクトを設定した場合はそれぞれのアトムごとにエフェクトが適用される。

const myState = atom({
  key: 'MyKey',
  default: null,
  effects_UNSTABLE: [
    () => {
      ...effect 1...
      return () => ...cleanup effect 1...;
    },
    () => { ...effect 2... },
  ],
});

atom 関数のオプションとして effects(現時点では effects_UNSTABLE)を指定する。
effects にはアトムエフェクト関数の配列を指定できる。

アトムエフェクト関数の型は以下の通り。

type AtomEffect<T> = ({
  node: RecoilState<T>,
  trigger: 'get' | 'set',
  setSelf: (
    | T
    | DefaultValue
    | Promise<T | DefaultValue>
    | ((T | DefaultValue) => T | DefaultValue),
  ) => void,
  resetSelf: () => void,
  onSet: (
    (newValue: T, oldValue: T | DefaultValue, isReset: boolean) => void,
  ) => void,

  getPromise: <S>(RecoilValue<S>) => Promise<S>,
  getLoadable: <S>(RecoilValue<S>) => Loadable<S>,
  getInfo_UNSTABLE: <S>(RecoilValue<S>) => RecoilValueInfo<S>,
}) => void | () => void;

アトムエフェクト関数が関数を返す場合、それはクリーンアップ用の関数として使用される。

@hysryt
Copy link
Owner Author

hysryt commented Sep 2, 2021

useRecoilTransaction

トランザクションは複数のアトムをアトミックに更新するための手段。

interface TransactionInterface {
  get: <T>(RecoilValue<T>) => T;
  set: <T>(RecoilState<T>,  (T => T) | T) => void;
  reset: <T>(RecoilState<T>) => void;
}

function useRecoilTransaction_UNSTABLE<Args>(
  callback: TransactionInterface => (...Args) => void,
  deps?: $ReadOnlyArray<mixed>,
): (...Args) => void

引数の deps の使い方は useCallback と同じ。

セレクタはサポートしていない。(今後サポートされる可能性あり)

function App() {
  const kouza = useRecoilValue(kouzaState);
  const saifu = useRecoilValue(saifuState);
  const [amount, setAmount] = useState(0);

  const onChangeAmount = e => setAmount(e.target.value - 0);

  const onClickAzukeire = useRecoilTransaction_UNSTABLE(({set}) => (amount) => {
    set(kouzaState, old => old + amount);
    set(saifuState, old => old - amount);
  }, []);

  const onClickHikiotoshi = useRecoilTransaction_UNSTABLE(({set}) => (amount) => {
    set(kouzaState, old => old - amount);
    set(saifuState, old => old + amount);
  }, [])

  return (
    <div>
      口座:{kouza}<br />
      財布:{saifu}<br />
      <input type="text" value={amount} onChange={onChangeAmount} /><br />
      <button onClick={() => onClickAzukeire(amount)}>預け入れ</button>
      <button onClick={() => onClickHikiotoshi(amount)}>引き落とし</button>
    </div>
  );
}

Reduxのアクションとリデューサを再現することもできる。

@hysryt
Copy link
Owner Author

hysryt commented Sep 3, 2021

constSelector

常に同じ値を返すセレクタ

function constSelector<T: Parameter>(constant: T): RecoilValueReadOnly<T>

定数でいいのにRecoilValue型を使用しないといけない、という時に便利。

@hysryt
Copy link
Owner Author

hysryt commented Sep 3, 2021

errorSelector

常にあたえられたエラーを投げるセレクタ

function errorSelector(message: string): RecoilValueReadOnly

@hysryt
Copy link
Owner Author

hysryt commented Sep 4, 2021

Loadable

Loadableオブジェクトはアトムやセレクタの「現在の値」を表す。
「現在の値」には以下の種類がある。

  • 通常の値
  • エラー
  • 保留中(pending中のPromiseなど)

Loadableオブジェクトは以下のインターフェースを持つ。

  • state - 現在の値の状態。'hasValue''hasError''loading' のいずれか。
  • contents - 現在の値。state'hasValue' であれば実際の値、'hasError' であれば Errorオブジェクト。'loading'の場合は toPromise() を使ってPromiseを取得する。

Loadableオブジェクトは以下のヘルパーメソッドを持つが、安定した(stableな)APIではない。

  • getValue() - state'hasValue' であれば実際の値を返し、'hasError' であればそのErrorオブジェクトをスローし、'loading' であればそのPromiseをスローする。
  • toPromise() - Promiseを返す。state'hasValue''hasError'の場合はすでに解決済みのPromiseを返す。
  • valueMaybe() - 利用可能な場合は値を返し、それ以外の場合は undefined を返す。
  • valueOrThrow() - 利用可能な場合は値を返し、それ以外の場合はエラーをスローする。
  • map() - Loadableを変換し、新しいLoadableにする。

@hysryt
Copy link
Owner Author

hysryt commented Sep 4, 2021

noWait()

function noWait<T>(state: RecoilValue<T>): RecoilValueReadOnly<Loadable<T>>

「アトムやセレクタ」を、「Loadableをラップするセレクタ」に変換するヘルパー関数。

const myQuery = selector({
  key: 'MyQuery',
  get: ({get}) => {
    const loadable = get(noWait(dbQuerySelector));

    return {
      hasValue: {data: loadable.contents},
      hasError: {error: loadable.contents},
      loading: {data: 'placeholder while loading'},
    }[loadable.state];
  }
})

waitForAll()

function waitForAll(dependencies: Array<RecoilValue<>>): RecoilValueReadOnly<UnwrappedArray>

function waitForAll(dependencies: {[string]: RecoilValue<>}): RecoilValueReadOnly<UnwrappedObject>

複数のアトムやセレクタを同時に評価するセレクタを作成するヘルパー関数。

waitForAllSettled()

function waitForAllSettled(dependencies: Array<RecoilValue<>>): RecoilValueReadOnly<UnwrappedArrayOfLoadables>

function waitForAllSettled(dependencies: {[string]: RecoilValue<>}): RecoilValueReadOnly<UnwrappedObjectOfLoadables>

複数のアトムやセレクタを同時に評価し、それぞれのLoadableを返すセレクタを作成するヘルパー関数。

この関数はそれぞれのLoadableが全て解決またはエラーとなるまで待機する。

waitForNone()

function waitForNone(dependencies: Array<RecoilValue<>>): RecoilValueReadOnly<UnwrappedArrayOfLoadables>

function waitForNone(dependencies: {[string]: RecoilValue<>}): RecoilValueReadOnly<UnwrappedObjectOfLoadables>

複数のアトムやセレクタを同時に評価し、それぞれのLoadableを返すセレクタを作成するヘルパー関数。
waitForAllSettled() が保留中のLoadableを待機するのに対し、こちらは待機しない。

「wait for none」は「何も待たない」という意味らしい。
noWait() が一つのステートを指定できるのに対し、waitForNone() は複数のステートを指定できる。

以下は解決済みのものから段階的に表示していく例。

function MyChart({layerQueries}: {layerQueries: Array<RecoilValue<Layer>>}) {
  const layerLoadables = useRecoilValue(waitForNone(layerQueries));

  return (
    <Chart>
      {layerLoadables.map((layerLoadable, i) => {
        switch (layerLoadable.state) {
          case 'hasValue':
            return <Layer key={i} data={layerLoadable.contents} />;
          case 'hasError':
            return <LayerErrorBadge key={i} error={layerLoadable.contents} />;
          case 'loading':
            return <LayerWithSpinner key={i} />;
        }
      })}
    </Chart>
  );
}

waitForAny()

function waitForAny(dependencies: Array<RecoilValue<>>): RecoilValueReadOnly<UnwrappedArrayOfLoadables>

function waitForAny(dependencies: {[string]: RecoilValue<>}): RecoilValueReadOnly<UnwrappedObjectOfLoadables>

複数のアトムやセレクタを同時に評価し、それぞれのLoadableを返すセレクタを作成するヘルパー関数。

少なくとも一つのLoadableが解決済みまたはエラーになるまで待機する。

@hysryt
Copy link
Owner Author

hysryt commented Nov 20, 2021

useRecoilRefresher

https://recoiljs.org/docs/api-reference/core/useRecoilRefresher/
https://recoiljs.org/docs/guides/asynchronous-data-queries/#userecoilrefresher

Query Refresh の一つとして Recoil 0.5 で追加されたフック。
セレクタのキャッシュ削除と再評価を行うコールバックを返す。

セレクタが別のセレクタに依存している場合、再帰的にキャッシュが削除される。

(セレクタは冪等でなければいけなかったのではないのかという疑問が残る。)

@hysryt
Copy link
Owner Author

hysryt commented Apr 11, 2022

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant