🗄️

状態管理関数が意外とシンプルに作れた

2022/01/07に公開

React を使わずに TypeScript で開発していたプロジェクトで、状態管理がしたいと思う機会がありました。

サーバに置いてすべてのクライアントで共有するわけではないから個々のクライアント側がローカルに持っていればいい。

クライアント側のコードではグローバルにいろんなファイルから特定の変数を参照したい…という状況でした。

sessionStorage を使う方法もありますが、文字列など特定のデータしか保存できないという制約があったり、セキュリティ的に中身が見られたくないという場合もあるかもしれません。

そこで、ライブラリを使わずに状態管理をしてみたら、意外とシンプルにまとまったという話です。

変数を直接定義

まずは変数を定義して、それを export することで対応しました。

states.ts
export let someState: string = '';
export let anotherState: number = 0;
main.ts
import { someState, anotherState } from 'states';

...

if (someCondition) {
  someState = 'used';
}
...

しかし、もしこれらが同じファイル内にあれば問題ないのですが、import された変数を書き換えることはできません。

なので書き換えるための関数を定義して、それを使って書き込みを行うことにしました。

states.ts
  export let someState: string = '';
  
+ export const setSomeState = (state: string) => {
+   someState = state;
+ };

  export let anotherState: number = 0;

+ export const setAnotherState = (state: number) => {
+   anotherState = state;
+ };
main.ts
- import { someState, anotherState } from 'states';
+ import { someState, setSomeState, anotherState } from 'states';

  ...

  if (someCondition) {
-   someState = 'used';
+   setSomeState('used');
  }
  ...

  console.info(someState);

さて、プログラムは動きはするのですが…変更したはずの someState が変更されません。

どうやら、この例において console.info で参照されたタイミングでは import してきた someState が参照されるので、
初期値である '' が渡されるだけであって、変更後のリアルタイムな値は参照されないようです。

変更が反映された値を取得するには、state の値を取得する関数を呼び出せばいいです。

states.ts
- export let someState: string = '';
+ let someState: string = '';
  
  export const setSomeState = (state: string) => {
    someState = state;
  };

+ export const someStateValue = () => someState; 

- export let anotherState: number = 0;
+ let anotherState: number = 0;

  export const setAnotherState = (state: number) => {
    anotherState = state;
  };
  
+ export const anotherStateValue = () => anotherState; 
main.ts
- import { someState, setSomeState, anotherState } from 'states';
+ import { someStateValue, setSomeState, anotherStateValue } from 'states';

  ...

  if (someCondition) {
    setSomeState('used');
  }
  ...
- console.info(someState)
+ console.info(someStateValue());

これで値の読み書きがちゃんとできるようになりました。

さて、state をまとめたコードはこのようになりました。

states.ts
  let someState: string = '';
  
  export const setSomeState = (state: string) => {
    someState = state;
  };

  export const someStateValue = () => someState; 

  let anotherState: number = 0;

  export const setAnotherState = (state: number) => {
    anotherState = state;
  };
  
  export const anotherStateValue = () => anotherState; 

たった 2 つだけの state なのに、無駄に長い気がしますし、どこからどこまでがひとまとまりかがわかりづらいです。

そこで React の useState や Recoil の useRecoilValue などのように、自分でそのような状態管理の関数を作ろうとなりました。

状態管理関数を作成

例えば Recoil はどうなっているのか考えると、

atom は引数にデフォルト値を取り、state に関する変数を返します。

使用するときには useRecoilValueuseSetRecoilState の引数にその変数を指定することで、state を参照するための値やそれを変更するための関数を得ることができます。

それと同じようなことをやればいいです。

ところで、先ほどは各 state をファイルの上階層で直接定義していましたが、
今度は何が来るかわからないため、state を格納するオブジェクトや Map を作成します。

そこの各 state にアクセスするためには一意なキーが必要になります。

このへんが Recoil で atom を作成するときにグローバルにユニークなキーが必要になる理由なのかもしれません。

一応自分で指定しなくても一意になるように乱数でも生成して設定すればよいので、今回はそうしています。

stateManager.ts
type State<T> = { key: string; dflt: T };

type SetState<T> = (newState: T) => void;

type UseStateReturnType<T> = [ T, SetState<T> ];

/** すべての state を格納する Map */
const states: Map<string, any> = new Map();

const generateRandomKey = () => String(Math.random()).slice(-10);

/** state を初期化する */
export const createState = <T>(dflt: T = null): State<T> => {
  let key = generateRandomKey();
  while (states.has(key)) {
    key = generateRandomKey();
  }
  states.set(key, dflt);
  return { key, dflt };
};

export const useValue = <T>(state: State<T>): T => {
  return states.get(state.key);
};

export const useSetState = <T>(state: State<T>): SetState<T> => {
  return (newState: T) => {
    states.set(state.key, newState);
  };
};

export const useState = <T>(state: State<T>): UseStateReturnType<T> => [
  useValue(state),
  useSetState(state),
];

ジェネリクスのおかげで型推論してくれるので、使う側でも型の恩恵を受けられます。

state の定義や使用は次のようになります。

states.ts
export const someState = createState('');

export const anotherState = createState(0);
main.ts
import { useValue, useSetState } from 'stateManager';
import { someState, anotherState } from 'states';

...

const setSomeState = useSetState(someState);

if (someCondition) {
  setSomeState('used');
}
...

const someStateValue = useValue(someState);

console.info(someStateValue);

定義も使用もコードがだいぶすっきりしました。

バリデーション

自作なので、setState するときに特定の条件を満たす値のみを受け入れる、みたいなことももちろんできます。

stateManager.ts
+ type IgnoreConditionFunction<T> = (state: T) => boolean;

- type State<T> = { key: string; dflt: T };
+ type State<T> = { key: string; dflt: T; ignore?: IgnoreConditionFunction<T> };

  ...

  /** state を初期化する */
- export const createState = <T>(dflt: T = null): State<T> => {
+ export const createState = <T>(dflt: T = null, ignore?: IgnoreConditionFunction<T>): State<T> => {
    let key = generateRandomKey();
    while (states.has(key)) {
      key = generateRandomKey();
    }
    states.set(key, dflt);
-   return { key, dflt };
+   return { key, dflt, ignore };
  };

  ...

  export const useSetState = <T>(state: State<T>): SetState<T> => {
+   const ignore = state.ignore;
    return (newState: T) => {
+     if (ignore !== undefined && ignore(newState)) {
+       return;
+     }
+ 
      states.set(state.key, newState);
    };
  };

  ...
states.ts
/** 1 以上 8 以下の整数と null のみ許容する  */
export const exampleState = createState<number>(
  null,
  (state) => state !== null && (!Number.isInteger(state) || state <= 0 || state >= 9)
);

そんなにコードの記述量も多くないですが、このように状態管理をすることができました。

この記事が参考になれば幸いです。

では 👋

Discussion