nato243 weblog.n-jitter brand iconweblog.n-jitter
テクノロジー

Reactのメモ化とコールバックの依存管理に疲れたら...シングルトンFLUXでよい...かも

2024.10.08
TypeScriptReactJSポエム
Reactのメモ化とコールバックの依存管理に疲れたら...シングルトンFLUXでよい...かも アイキャッチ


memo化 + 状態管理ライブラリ = つらい

Reactコンポーネントのメモ化はしたいが、
とうていuseCallbackの依存管理なんか正確にできないよ...なんてことがある。
データ操作に付随する処理が多すぎてとうてい全部影響範囲を洗い出せない、みたいなときだ。

かといってuseCallbackを外すと、毎回コールバック関数が再生成されるので、メモ化の意味がなくなる。

逆にuseCallbackの依存を書くのを諦めると、useCallbackの中の関数はキャッシュ当時の状況のスコープになるので、おなじ関数なのにレンダリングの中で複数時系列が存在することになり、もはや読んでもなにがなんだかデバッグしづらくなる。

そもそもキャッシュ実装というのは、CDNであろうがRedisであろうが、けっこう気軽に導入する割には本質的に難しいものなので、基本Reactの世界でもメモ化なんかやらないにこしたことはない。

...ない、ないのだが、複雑なインタラクションを多量に扱うアプリではそうも言ってられない。

immutableな状態管理ライブラリの良し悪し

状態管理系ライブラリはReact.Contextと結びついたレンダリングと寿命をともにするイミュータブルな状態を提供するものが多い。
インスタンス化と(半)グローバル変数化のいいとこ取りができるかんじで、普段なら歓迎すべきだ。

ただこのReactのContextと結びつきは、要するにReactのレンダリングのルールに乗っ取らないとデータの最新状態の取得すら関数スコープと計算のための依存を気にしなければいけなくなることを意味する

ただの差分レンダリングライブラリがロジックの中核になるのはキモすぎる感がある。ロジック主体の複雑なWebアプリならなおさらだ。


mutableなシングルトンで良いんじゃないか


FLUX時代の思想と同じく、オブザーバブルついたグローバル変数で構わないんじゃないか...というのを年何回か考える。

以下が実験。

import {FC, memo, useCallback, useState} from "react";
import {atom, useAtom} from "jotai";


type GlobalState = {
  count: number;
  text: string;
  version:number;
};


type Updater = {
  setText(str : string): Updater;
  setCount(count: number): Updater;
  build(): GlobalState;
}

let globalState: GlobalState = {
  count: 0,
  text: "",
  version: 0,
}

const updater = (_from : GlobalState): Updater => {

  const from = {..._from};

  return {
    setText: (str: string) => {
      return updater({
        ...from,
        text: str,
      });
    },
    setCount: (count: number) => {
      return updater({
        ...from,
        count,
      });
    },
    build: () => {
      from.version++;
      return {...from} as GlobalState;
    }
  }
}

const GlobalStateUpdateCounter = atom(0);

const useGlobalState = () => {

  const [_ , forceUpdate] = useAtom(GlobalStateUpdateCounter);

  return {
    value: ():GlobalState => {
      return {...globalState};
    },
    set: (newState: GlobalState) => {
      globalState = {...newState};
      forceUpdate(i => i+1);
    }

  }
}


export const JotaiPlaygroundPage: FC<any> = () => {

  const globalState = useGlobalState();

  const text = globalState.value().text;

  const increment = useCallback(()=>{
    globalState.set(
      updater(globalState.value())
        .setCount(globalState.value().count + 1)
        .build()
    )

  } , []);

  const setText = useCallback((txt : string)=>{
    globalState.set(
      updater(globalState.value())
        .setText(txt)
        .build()
    )
  },[]);


  return (
    <div style={{padding: `4px` , backgroundColor: `#F0F0F0`  }}>
      <div>
        <MyApp
          count={globalState.value().count}
          onClick={increment}
        />
        <TextApp
          text={text}
          onChange={setText}
        />
        <StandAlone />
        <GlobalStateViewer />
      </div>
    </div>
  )
}

type MyAppProps = {
  count: number;
  onClick: () => void;
}

let clickRenderCount = 0;

const MyApp = memo((props : MyAppProps)=>{

  clickRenderCount++;

  return (
    <div
      style={{
        padding: `4px`,
        backgroundColor: `#8f939b`,
        marginTop: `8px`,
      }}
    >
      <ul>
        <li>
          共通count : {props.count}
        </li>
        <li>
          このセクションのレンダリング数 : {clickRenderCount}
        </li>
      </ul>
      <button onClick={props.onClick}>
        カウントアップ (共通カウント)
      </button>
    </div>
  )
})

type TextAppProps = {
  text: string;
  onChange: (text: string) => void;
}

let textRenderCount = 0;

const TextApp = memo((props : TextAppProps)=> {
  textRenderCount++;
  return (
    <div
      style={{
        padding: `4px`,
        backgroundColor: `#d7d1d1`,
        marginTop: `8px`,
      }}
    >
      <div>
        このセクションのレンダリング数: {textRenderCount}
      </div>
      <div>
        <span>
          text (共通ステート)
        </span>
        <input
          type="text"
          value={props.text}
          onChange={(e)=>{
            props.onChange(e.target.value);
          }}
        />
      </div>
    </div>
  )
})

let standAloneRenderCount = 0;

const StandAlone = memo(()=>{

  const globalState = useGlobalState();

  const [localState, setLocalState] = useState(0);

  standAloneRenderCount++;

  const onClickLocalState = ()=> {
    setLocalState(v => v + 1);
  }

  return (
    <div
      style={{
        padding: `4px`,
        backgroundColor: `#bcc9b8`,
        marginTop: `8px`,
      }}
    >
      <div>
        このセクションのレンダリング数: {standAloneRenderCount}
      </div>
      <div>
        globalStateVersion: {globalState.value().version}
      </div>
      <div>
        localStateValue: {localState}
      </div>
      <div>
        <button onClick={onClickLocalState}>
          count up local state
        </button>
      </div>
    </div>
  )
})

const GlobalStateViewer = memo(()=>{
  const globalState = useGlobalState();

  return (
    <div
      style={{
        padding: `8px`,
        backgroundColor: `#a192bb`,
        marginTop: `8px`,
      }}

    >
      <div style={{padding: `2px`}}>
        Global State Viewer
      </div>
      <pre>
        {JSON.stringify(globalState.value(), null, 2)}
      </pre>
    </div>
  )
})


思った通りの分離にはなりそうだ。

jotaiのような管理ライブラリを、GlobalStateが変わったときのオブザーバブル要求のためだけに使っているのがポイント。(これだけならjotaiでなくとも、自分でReact.Contextで適当なイベントリスナー書いても良いかもしれない)

いわゆるベストプラクティスからは遠いが、Getのキャッシュはきこめまやかに、Setはグローバルなストアで一元管理...くらいの温度感で運用したいとなるとこういうのもありじゃないかとは思う。

ロジック主体の複雑なアプリでは、仮想DOMやWeb文脈に結びついた状態管理ライブラリ選定などより、データとレンダリングが分離されるアークテクチャのほうが合ってそうな気もする
ゲームエンジン的思想にもどるというか...


まあでもReact Compilerがゲームチェンジしてくれるのを期待します。