LoginSignup
5
5

More than 3 years have passed since last update.

非同期処理に対応したフレームワークに依存しないTypeScript + Reactステート管理

Last updated at Posted at 2020-05-19

非同期処理に対応したフレームワークに依存しないTypeScript + Reactステート管理

ReactHooksが使えるようになったことで、ReduxやmobXなどのフレームワークを用いずとも本格的な状態管理がお手軽に出来るようになり、個人的には良く利用しているのですが、周囲ではReduxに押し負け気味なので理論武装の為にも記事にしてみました。

標準機能のみで実装しておけば、やや戦国時代に突入しそうな気配のあるフレームワークの将来を気にする必要も無いのもメリットです。

ステート管理の代表的な課題

他にも色々ありますが、自分がはじめに遭遇した課題は以下の3点でした。

  • バケツリレー回避
    • いつものやつです。ルート要素で定義した値を孫要素で用いたい場合、「ルート -> 親 -> 孫」と値を手渡しする必要があり、記述が面倒なのは勿論、仕様変更時の修正範囲も多くなります。皮肉にもコンポーネント化を進めて粒度を小さくすればするほどバケツリレーの被害が大きくなるので真っ先に対応する必要があります。
  • 非同期処理
    • Reactの ※)render関数はコンポーネントを同期的に返却 する仕様のため、バックエンド側とのやりとりによって表示内容を変える場合は、ステート管理側で非同期処理を解決しておく必要があります。Reduxではおなじみの課題です。
      • ※) Supense が正式リリースされれば状況は変わると思います。
  • 段階的な状態遷移
    • Fluxが苦手とする項目です。一定以上の時間を要する処理を行っている間ユーザーの操作を止めておく場合、ローディング画面プログレスバーを表示しておく必要が出てくると思います。その際、「ボタン押下前 -> ローディング画面 -> 処理結果反映後」などの 1アクションに対して複数回の画面表示状態遷移 が発生するため、useReducerから作られるStateのみだと実現が難しいです。

useReducerを用いたステート管理

とりあえずの土台となる、課題の解決を隅においた、useReducerによる単純なステート管理の例を考えます。

押しボタンを押下したら歩行者信号が赤から青に変化する単純なものです。

001.png

概要は以下になります。

a001.png

ディレクトリ構成

ディレクトリ構成は以下のようにしました。

※ index.tsxなどは記述を省いています。

 src
  ├components  // コンポーネント郡・・・presentersとは疎結合
  │ ├Intersection.tsx
  │ ├PedestrianButton.tsx
  │ └PedestrianSignal.tsx
  ├domain      // ドメイン層・・・ビジネスルールの記述。ここから他の層は参照しない
  │ └entities
  │   └PedestrianSignal.ts
  ├presenters  // プレゼンテーション層・・・画面表示の状態管理はこの層で行う
  │ ├actions   //  アクションの定義
  │ │ └IntersectionActions.ts
  │ ├reducers  //   Reducerの定義
  │ │ ├CrossingRequestReducer.ts
  │ │ └ResetReducer.ts
  │ ├IntersectionReducer.ts
  │ └IntersectionViewState.ts
  └App.tsx    // コンテナ部・・・presenteresとcomponentsを紐付け

ドメインモデルの定義

歩行者信号の状態を列挙型のValueObjectとして定義します。
enumを用いると諸々の制限が出てくるので、ここではunion typeで定義します。

※ 記述が冗長になるので、import宣言は全て省略します。

src/domain/entities/PedestrianSignal.ts
export const PedestrianSignalStates = {
    Red: "Red",
    Blue: "Blue"
} as const;
export type PedestrianSignalState = typeof PedestrianSignalStates[keyof typeof PedestrianSignalStates];

画面表示状態の定義

一つ前で定義した PedestrianSignalState 型の値を持つオブジェクトを
画面表示状態の型として定義します。

src/presenters/IntersectionViewState.ts
export interface IntersectionViewState {
    pedestrianSignal: PedestrianSignalState;
}

Actionの定義

Actionについては、押しボタン押下時の要求 、「CrossingRequestAction」と リセット の2つを定義します。

src/presenters/actions/IntersectionActions.ts
export interface IntersectionActions {
    crossingRequest: any; // 押しボタン時 歩行者への要求
    reset: any; // リセット
}

export interface IntersectionAction  {
    type: keyof IntersectionActions;
    params?: any
}

Reducerの定義と実装

Reducerの型定義と実装です。

ここでは、switch文 で種別を判定し、各Action毎のReducerを呼び出します。

src/presenters/IntersectionReducer.ts
export type IntersectionReducers = Reducer<IntersectionViewState, IntersectionAction>;

export type IntersectionReducer = (
    state: IntersectionViewState,
    params: IntersectionAction["params"]
) => IntersectionViewState;

export const Reducers: IntersectionReducers = (state, action) => {

    switch(action.type) {
        case "crossingRequest":
            return CrossingRequestReducer(state, action.params);
        default:
            return ResetReducer(state, action.params);
    }

}

crossingRequestの実装です。歩行者信号を赤から青に変化させます。

src/presenters/reducers/CrossingRequestReducer.ts
export const CrossingRequestReducer: IntersectionReducer = (state) => {
    return {
        ...state,
        pedestrianSignal: PedestrianSignalStates.Blue
    }
}

resetの実装です。歩行者信号を赤に戻します。

src/presenters/reducers/ResetReducer.ts
export const ResetReducer: IntersectionReducer = (state) => {
    return {
        ...state,
        pedestrianSignal: PedestrianSignalStates.Red
    }
}

Componentの実装

コンポーネントは「Intersection」を親要素として、「PedestrianSignal」と「PedestrianButton」を子として持つ形で、
3ファイルに分けて実装します。

それぞれ 「state」「dispatcher」 をプロパティとして受け取ります。

Intersection

本例ではSVGを使って画面を表現。

リセット処理へのリンクのみこのコンポーネントの直下で保持しています。

src/components/Intersection.tsx
export function Intersection(params: {
    state: IntersectionViewState,
    dispatcher: Dispatch<IntersectionAction>
}) {
    const {state, dispatcher} = params;
    return (
        <svg xmlns="http://www.w3.org/2000/svg" width="500px" viewBox="0 0 300 240">
            <g transform="translate(10,10)">
                <PedestrianSignal state={state} dispatcher={dispatcher} />
            </g>
            <g transform="translate(150,45)" >
                <PedestrianButton state={state} dispatcher={dispatcher} />
            </g>
            { /*リセット処理へのリンク*/ }
            <g transform="translate(150,90)" style={{
                cursor: "pointer",
                display: state.pedestrianSignal === PedestrianSignalStates.Blue ? undefined : "none"
            }} onClick={ () => {
                dispatcher( { type:"reset"});
            }}>
                <text x="70.5" y="125" textAnchor="middle" fontSize="12" fill={"blue"
                }>リセット</text>
            </g>
        </svg>
    );
}
PedestrianSignal

歩行者信号コンポーネント。青信号と赤信号を角丸矩形で表現し、ステータスに合わせて色を変化させています。

src/components/PedestrianSignal.tsx
export function PedestrianSignal(params: {
    state: IntersectionViewState,
    dispatcher: Dispatch<IntersectionAction>
}) {
    const {state} = params;
    return (
        <React.Fragment>
            <rect fill="#D8D8D8" x="0" y="0" width="105" height="216" rx="8" />
            { /*青信号*/ }
            <rect fill={ state.pedestrianSignal === PedestrianSignalStates.Blue ? "#018CBA" : "#002F2E" } x="8" y="119" width="88" height="88" rx="8" />
            { /*赤信号*/ }
            <rect fill={ state.pedestrianSignal === PedestrianSignalStates.Blue ? "#530103" : "#DF0409" } x="8" y="10" width="88" height="88" rx="8" />
            <g transform="translate(38, 17)" fill="#CBC9C9" >
                <path d="M13,13 C17,13 20,10 20,7 C20,3 17,0 13,0 C9,0 6,3 6,7 C6,10 9,13 13,13 Z" />
                <path d="M19,16 L13,16 L7,16 C4,16 0,20 0,23 L0,46 C0,47 1,48 3,48 C4,48 3,48 5,48
            L6,68 C6,70 8,71 9,71 C10,71 12,71 13,71 C14,71 16,71 17,71 C18,71 20,70 20,68
            L21,48 C23,48 22,48 23,48 C25,48 26,47 26,46 L26,23 C26,20 22,16 19,16 Z" />
            </g>
            <g transform="translate(26, 128)" fill="#CBC9C9" >
                <path d="M51,33 L46,26 C45,25 43,24 42,23 L32,18 C30,17 28,16 26,16 L23,16 C22,16
            20,16 19,18 L10,26 L2,28 C0,28 -0,30 0,31 L0,31 C0,33 2,34 3,34 L10,33 C11,32 13,32 14,31
            L18,29 L18,41 C18,42 18,43 18,44 L5,66 C4,68 4,70 6,70 L6,71 C8,71 9,71 10,70 L25,49 L31,61
            C31,62 32,62 32,63 L44,70 C45,71 47,71 48,69 L48,69 C49,69 49,68 49,67 C49,66 48,65 48,65
            L38,57 L32,41 L33,27 L40,29 L47,36 C48,37 49,37 50,36 L50,36 C51,36 51,34 51,33 Z" />
                <path d="M23,14 C27,15 30,12 31,8 C32,4 29,1 25,0 C21,-1 17,2 17,6 C16,10 19,13 23,14 Z" />
            </g>
        </React.Fragment>
    );
}
PedestrianButton

押しボタンコンポーネント。
押しボタンは信号が赤の時のみ押下可能に、また「押してください」のテキストも信号が赤の時のみ表示されるようにしています。

src/components/PedestrianButton.tsx
export function PedestrianButton(params: {
    state: IntersectionViewState,
    dispatcher: Dispatch<IntersectionAction>
}) {
    const {state, dispatcher} = params;
    return (
        <React.Fragment>
            <rect fill="#D5BE2D" x="0" y="0" width="141" height="145" rx="8" />
            <rect fill="#0D0101" x="21" y="23" width="99" height="24" />
            <rect fill="#0D0101" x="21" y="108" width="99" height="24" />
            <circle fill="#959595" cx="11.5" cy="77.5" r="6.5" />
            <circle fill="#959595" cx="130.5" cy="77.5" r="6.5" />
            <text x="70.5" y="125" textAnchor="middle" fontSize="12" fill={
                state.pedestrianSignal === PedestrianSignalStates.Red ? "red" : "none"
            }>おしてください</text>
            <g style={{
                cursor: state.pedestrianSignal === PedestrianSignalStates.Red ? "pointer" : "not-allowed"
            }} onClick={ state.pedestrianSignal === PedestrianSignalStates.Red ? () => {
                dispatcher( { type:"crossingRequest"});
            } : undefined}>
                <ellipse stroke="#979797" strokeWidth="2" fill="#B23236" cx="71" cy="77" rx="21" ry="20"/>
            </g>
        </React.Fragment>
    );
}

コンテナ部の実装

App.tsxで useReducer を呼び出し、 componentspresentersの紐付けを行います。

src/App.tsx
function App() {
    const [state, dispatcher] = useReducer(
        Reducers,
        {pedestrianSignal: PedestrianSignalStates.Red}
    );
    return (
        <Intersection state={state} dispatcher={dispatcher} />
    );
}
export default App;

↑までのコードはココに上げてあります。

バケツリレー回避の方法

ここからは最初に代表的な課題を解決していきます。
まずはバケツリレーの回避についてですが、答えは明確で ContextAPI を用いて解決出来ます。

a002.png

はじめにpresenters/contexts 配下に「IntersectionContext.ts」を作成します。

 src
  ├components
  │ ├Intersection.tsx
  │ ├PedestrianButton.tsx
  │ └PedestrianSignal.tsx
  ├domain    
  │ └entities
  │   └PedestrianSignal.ts
  ├presenters
  │ ├actions 
  │ │ └IntersectionActions.ts
+ │ ├contexts  //   Contextの定義
+ │ │ └IntersectionContext.ts
  │ ├reducers
  │ │ ├CrossingRequestReducer.ts
  │ │ └ResetReducer.ts
  │ ├IntersectionReducer.ts
  │ └IntersectionViewState.ts
  └App.tsx

実装は以下のようになります。

createContextに管理したいオブジェクトの初期値を渡してコンテキストが生成できます。

今回は、statedispatcherを属性として持つオブジェクトをコンテキストに格納します。

src/presenters/contexts/IntersectionContext.ts
export const IntersectionContext = createContext<{
    state: IntersectionViewState,
    dispatcher: Dispatch<IntersectionAction> }>(
    {
        state: { pedestrianSignal: PedestrianSignalStates.Red },
        dispatcher:() => {}
    }
);

作成したコンテキストに、App.tsx内のuseReducerによって得られた statedispatcherProvider 要素経由で組み込みます。

これによって、子孫コンポーネントではバケツリレーをせずとも useContext を使うことでstatedispatcherにアクセスすることが出来るようになります。

コンテナ部
src/App.tsx
function App() {
    const [state, dispatcher] = useReducer(
        Reducers,
        {pedestrianSignal: PedestrianSignalStates.Red}
    );
    return (
+       <IntersectionContext.Provider value={{
+            state: state,
+            dispatcher: dispatcher
+       }}>
            <Intersection 
-              state={state} dispatcher={dispatcher} 
            />
+       </IntersectionContext.Provider>
    );
}
export default App;

早速子孫コンポーネントをバケツリレーからuseContextに書き換えて行きます。

Intersection
src/components/Intersection.tsx
export function Intersection(
-    params: { state: IntersectionViewState, dispatcher: Dispatch<IntersectionAction> }
) {
-   const {state, dispatcher} = params;
+   const {state, dispatcher} = useContext(IntersectionContext)
    return (
        <svg xmlns="http://www.w3.org/2000/svg" width="500px" viewBox="0 0 300 240">
            <g transform="translate(10,10)">
                <PedestrianSignal
-                    state={state} dispatcher={dispatcher}
                />
            </g>
            <g transform="translate(150,45)" >
                <PedestrianButton
-                    state={state} dispatcher={dispatcher}
                />
            </g>
            :
            :
PedestrianSignal
src/components/PedestrianSignal.tsx
export function PedestrianSignal(
-   params: { state: IntersectionViewState, dispatcher: Dispatch<IntersectionAction>}
) {
-    const {state} = params;
+    const {state} = useContext(IntersectionContext);
        :
        :
}
PedestrianButton
src/components/PedestrianButton.tsx
export function PedestrianButton(
-    params: { state: IntersectionViewState, dispatcher: Dispatch<IntersectionAction> }
) {
-   const {state, dispatcher} = params;
+   const {state, dispatcher} = useContext(IntersectionContext);
        :
        :

この程度の規模ではあまり恩恵は感じませんが、後々ボディブローのように効いてきます。

↑までのコードはココに上げてあります。

非同期処理の解決方法

useReducerをそのまま使うのは取り回しが悪いので、カスタムフックuseAsyncReducer を定義します。

useReduceruseState を使ったカスタムフックに置き換えています。

a003.png

 src
  ├components
  │ ├Intersection.tsx
  │ ├PedestrianButton.tsx
  │ └PedestrianSignal.tsx
  ├domain
  │ └entities
  │   └PedestrianSignal.ts
  ├presenters
  │ ├actions
  │ │ └IntersectionActions.ts
  │ ├contexts
  │ │ └IntersectionContext.ts
  │ ├reducers
  │ │ ├CrossingRequestReducer.ts
  │ │ └ResetReducer.ts
  │ ├IntersectionReducer.ts
  │ └IntersectionViewState.ts
+ ├shared  
+ │ └UseAsyncReducer.ts
  └App.tsx

以下のように useState とPromiseを組み合わせれば最低限の非同期処理を実現したカスタムフックを実装出来ます。

src/shared/UseAsyncReducer.ts

// AsyncReducer返却値の型
export type ReducerResult<S> = Promise<S>;

// AsyncReducerの型
export type AsyncReducer<S, P, > = (
    state: S,
    params: P,
) => ReducerResult<S>;

// AsyncReducerの定義
export function useAsyncReducer<S,P>(
    reducers: AsyncReducer<S, P>,
    initialState: S,
): [S, Dispatch<P>] {
    const [state, setState] = useState<S>( initialState )
    const dispatcher = (params: P) => {
        handleResult(reducers(state, params), setState);
    }
    return [state, dispatcher];
}

function handleResult<S, U>(
    result: ReducerResult<S>,
    setState: Dispatch<SetStateAction<S>>
) {
    (result as Promise<S>).then((state) => {
        setState(state);
    });
}

非同期処理と同期処理を同一のフックで扱う

テキスト入力のタイピングに合わせてステートを変化させたい場合など、Promiseawait するのがよろしくないケースは必ず存在します。その際、非同期処理と同期処理でフックを分けるのは使い勝手が悪いので、 同期的な返却値も同じフックで扱えるように 上記をカスタマイズします。

src/shared/UseAsyncReducer.ts

- export type ReducerResult<S> = Promise<S>;
+ export type ReducerResult<S> = Promise<S> | S; // 同期的な返却値も受入可能にする

返却値がPromiseかどうかを判別し、Promiseでなかった場合はそのままsetStateを呼び出すようにします。

※当然、thenとcatchという属性を持っていればPromiseでなくともisPromiseの判定はtrueとなるため、注意してください。

src/shared/UseAsyncReducer.ts
function handleResult<S, U>(
    result: ReducerResult<S>,
    setState: Dispatch<SetStateAction<S>>
) {
+   if (isPromise(result)) {
        (result as Promise<S>).then((state) => {
            setState(state);
        });
+       return;
+   }
+   const {setState} = stateRef.current;
+   setState(result as S);
}

+ function isPromise(maybe: any): boolean {
+   return !!(maybe.then && maybe.catch );
+ }

この修正により、今まで実装済みの同期Reducerがそのまま動作するようになります。

dispatcherの同一性を確保する

さらに修正を加えます。
現状は、 useAsyncReducer をを呼び出す度に返却値の dispatcherが新規オブジェクト となってしまうため、コンポーネントによっては余分なレンダリングが発生します。

これの解決のため、 useRef を使って dispatcherの同一性を確保 します。

※ useRefの特性はこの記事が参考になります。

src/shared/UseAsyncReducer.ts
export function useAsyncReducer<S,P>(
    reducers: AsyncReducer<S, P>,
    initialState: S,
): [S, Dispatch<P>] {

    const [state, setState] = useState<S>( initialState )
+   const paramsRef = useRef<[AsyncReducer<S, P>,]>([reducers]);
+   const stateRef = useRef<{state:S, setState:Dispatch<SetStateAction<S>>}>({ state, setState});
+   const dispatcherRef = useRef<Dispatch<P> | null>(null);

+   if ( !dispatcherRef.current ) {
+       dispatcherRef.current = (params: P) => {
-   const dispatcher = (params: P) => {
+           const [reducers] = paramsRef.current;
+           const {state} = stateRef.current;
-       handleResult(reducers(state, params), setState);
+       handleResult(reducers(state, params), stateRef);
        }
+   }     
+   paramsRef.current = [reducers];
+   stateRef.current = { state, setState};
+   return [state, dispatcherRef.current];
-   return [state, dispatcher];
}

function handleResult<S, U>(
    result: ReducerResult<S>,
-   setState: Dispatch<SetStateAction<S>>
+   stateRef: MutableRefObject<{
+       state:S, setState:Dispatch<SetStateAction<S>>
+   }>,
) {
    if (isPromise(result)) {
        (result as Promise<S>).then((state) => {
+           const {setState} = stateRef.current;
            setState(state);
        });
        return;
    }
+   const {setState} = stateRef.current;
    setState(result as S);
}

非同期処理の組み込み

ここまでで事前準備が整ったので、実際に非同期処理を組み込んでみます。
以下の図のように、押しボタンを押下後3秒後 歩行者信号が青となるよう実装します。

002.png

コンテナ部

useReducer の呼び出し箇所を useAsyncReducer に変更します。

src/App.tsx
-    const [state, dispatcher] = useReducer(
+    const [state, dispatcher] = useAsyncReducer(
IntersectionReducer

「IntersectionReducers」、「IntersectionReducer」の型をそれぞれ useAsyncReducer に合わせて変更します。

src/presenters/IntersectionReducer.ts
- export type IntersectionReducers = Reducer<IntersectionViewState, IntersectionAction>;
+ export type IntersectionReducers = AsyncReducer<IntersectionViewState, IntersectionAction>;

export type IntersectionReducer = (
    state: IntersectionViewState,
    params: IntersectionAction["params"]
- ) => IntersectionViewState;
+ ) => ReducerResult<IntersectionViewState>;

CrossingRequestReducer

3秒後にステート変化を行うようsleep関数を組み込みます。

src/presenters/reducers/CrossingRequestReducer.ts
- export const CrossingRequestReducer: IntersectionReducer = (state) => {
+ export const CrossingRequestReducer: IntersectionReducer = async (state) => {
+   await sleep(3000);
    return {
        ...state,
        pedestrianSignal: PedestrianSignalStates.Blue
    }
}
+ export function sleep(time: number): Promise<any> {
+   return new Promise( (resolve) => {
+       setTimeout(() => {
+           resolve();
+       }, time);
+   });
+ }

↑までのコードはココに上げてあります。

段階的な状態遷移の解決方法

redux-saga に倣って Generatorsを用いることで解決します。

AsyncGenerator の返却値としてステートを返すことで、段階的な状態遷移が出来るようにします。

以下のようにReducerの返却値としてAsyncGeneratorも受け取れるように修正します。

src/shared/UseAsyncReducer.ts
- export type ReducerResult<S> = Promise<S> | S;
+ export type ReducerResult<S> = Promise<S> | S | AsyncGenerator<S>;

handleResult関数にGeneratorかどうかの判別と、受け取ったあとの反復処理を記述します。

※ここも同じく、return、next、throwという属性を持っていればGeneratorでなくともisGeneratorの判定はtrueとなります。Reducerの返却値にtypeを持たせるなどの方法もあると思いますが、Reducerの記述量が増えるため、その辺りはトレードオフです。

src/shared/UseAsyncReducer.ts
function handleResult<S, U>(
    result: ReducerResult<S>,
    stateRef: MutableRefObject<{
        state:S, setState:Dispatch<SetStateAction<S>>
    }>,
) {
    if (isPromise(result)) {
        (result as Promise<S>).then((state) => {
            const {setState} = stateRef.current;
            setState(state);
        });
        return;
    }
+   if (isGenerator(result)) {
+       const generator = result as AsyncGenerator<S>
+       (async function() {
+           for await (const state of generator) {
+               const {setState} = stateRef.current;
+               setState(state);
+           }
+       })();
+       return;
+   }
    const {setState} = stateRef.current;
    setState(result as S);
}

function isPromise(maybe: any): boolean {
    return !!(maybe.then && maybe.catch );
}

+function isGenerator(maybe: any): boolean {
+   return !!(maybe.return && maybe.next && maybe.throw);
+}

これでカスタムフックの準備は整いました。

段階的な状態遷移の組み込み

実際にサンプルプログラムに組み込んでみます。
今回は歩行者ボタンを押下した直後の3秒間の待ち時間の間、歩行者ボタン上に 「おまちください」 とテキストが表示されるようにします。

003.png

まずは表示用ステートに waiting の項目を追加します。

src/presenters/IntersectionViewState.ts
export interface IntersectionViewState {
    pedestrianSignal: PedestrianSignalState;
+   waiting: boolean;
}

合わせて、Context、コンテナの初期値を更新します。

src/presenters/contexts/IntersectionContext.ts
-state: { pedestrianSignal: PedestrianSignalStates.Red },
+state: { pedestrianSignal: PedestrianSignalStates.Red, waiting: false },
src/App.tsx
-state: { pedestrianSignal: PedestrianSignalStates.Red },
+state: { pedestrianSignal: PedestrianSignalStates.Red, waiting: false },

次に、CrossingRequestReducer の 非同期関数を AsyncGenerator に置き換え、yield で都度ステートを返却するようにします。

- export const CrossingRequestReducer: IntersectionReducer = async (state) => {
+ export const CrossingRequestReducer: IntersectionReducer = async function* (state) {
+   yield {
+       ...state,
+       waiting: true
+   };
    await sleep(3000);
-   return {
+   yield {
        ...state,
        pedestrianSignal: PedestrianSignalStates.Blue,
+       waiting: false
    }
}

表示側 押しボタンのコンポーネントは以下のように修正します。

components/PedestrianButton.tsx
export function PedestrianButton() {
    const {state, dispatcher} = useContext(IntersectionContext);
+    const ready = state.pedestrianSignal === PedestrianSignalStates.Red && !state.waiting;
    return (
        <React.Fragment>
            <rect fill="#D5BE2D" x="0" y="0" width="141" height="145" rx="8" />
            <rect fill="#0D0101" x="21" y="23" width="99" height="24" />
            <rect fill="#0D0101" x="21" y="108" width="99" height="24" />
            <circle fill="#959595" cx="11.5" cy="77.5" r="6.5" />
            <circle fill="#959595" cx="130.5" cy="77.5" r="6.5" />
+            <text x="70.5" y="39" textAnchor="middle" fontSize="12" fill={
+                state.waiting ? "red" : "none"
+            }>おまちください</text>
            <text x="70.5" y="125" textAnchor="middle" fontSize="12" fill={
-                state.pedestrianSignal === PedestrianSignalStates.Red ? "red" : "none"
+                ready ? "red" : "none"
            }>おしてください</text>
            <g style={{
-                cursor: state.pedestrianSignal === PedestrianSignalStates.Red ? "pointer" : "not-allowed"
+                cursor: ready ? "pointer" : "not-allowed"
            }} onClick={
-                state.pedestrianSignal === PedestrianSignalStates.Red ? () => {
+                ready ? () => {
                dispatcher( { type:"crossingRequest"});
            }:undefined}>
                <ellipse stroke="#979797" strokeWidth="2" fill="#B23236" cx="71" cy="77" rx="21" ry="20"/>
            </g>
        </React.Fragment>
    );
}

AsyncGeneratorを使った状態遷移を導入することで、ローディング画面だけでなくページ切り替え時のめくり or スライドアニメーションなど、コンポーネント側に副作用を持たせた実装になりがちだった様々な部分をステート管理に一律で含めることが出来るようになります。

↑までのコードはココに上げてあります。

おまけ

ステート管理という本筋からは少しはずれ、且つ、好みによるところもある内容ではありますが、個人的には是非加えて置いた方が良いと思われる内容を2点、紹介しておきます。

Reducerとビジネスロジックの分離

今回のサンプルでは、内容が単純だったこともあり、 CrossingRequestReducer の非同期処理をインラインでそのまま記述しましたが、実際バックエンドとのやりとりが発生する場合は、ここで fetch など通信処理を直接記述することになるため、Reducerとバックエンド側が密結合 となります。この状態は表示系に限定したテストや作業分担を行う場合に勝手が悪いので、Reducerの呼び出し時に抽象化されたAPIを渡すことで依存性の注入を行うよう修正した方が良いです。

また、今回のサンプルは信号の切り替えだけの単純なものでしたが、ある程度の規模を持ったアプリケーションの場合、表示系の状態もアクションの数も多岐に渡ります。さらに、Reducerにビジネスロジックまで持たせてしまうとReducerが責務過多となり不具合発生時の原因切り分けも難しくなるため、 Reducerには一切ビジネスロジックを持たせず、API経由のビジネスロジックの呼び出しと結果を受けた画面状態の制御に特化した方が全体の見通しが良くなります。

以下に、今回のサンプルを用いて実際にビジネスロジックの分離を行った例を示します。

ReducerからUseCasesにアクセスする形になります。

a004.png

注入されるビジネスロジックを内包したAPIの名前は Clean Architecture に倣って useCasesとしました。

ディレクトリ構成
 src
  ├components
  │ ├Intersection.tsx
  │ ├PedestrianButton.tsx
  │ └PedestrianSignal.tsx
  ├domain
  │ ├entities
  │ │ └PedestrianSignal.ts
+ │ └use_cases // ビジネスロジックの記述 プラットフォームやdomainパッケージより外に依存したコード実装はここで記述しない。
+ │   └IntersectionUseCases.ts
+ ├interactors // use_casesの実装部。環境に合わせたコードを記述する。
+ │ └MockIntersectionInteractor.ts
  ├presenters
  │ ├actions
  │ │ └IntersectionActions.ts
  │ ├contexts
  │ │ └IntersectionContext.ts
  │ ├reducers
  │ │ ├CrossingRequestReducer.ts
  │ │ └ResetReducer.ts
  │ ├IntersectionReducer.ts
  │ └IntersectionViewState.ts
  ├shared  
  │ └UseAsyncReducer.ts
  └App.tsx
useAsyncReducer

カスタムフックから変更します。 AsyncReducer, AsyncReducersの定義に useCases を追加し、reducers呼び出し部の引数にも加えます。

src/shared/UseAsyncReducer.ts
export type ReducerResult<S> = Promise<S> | S | AsyncGenerator<S>;
- export type AsyncReducer<S, P, > = (
+ export type AsyncReducer<S, P, U> = (
    state: S,
    params: P,
+   useCases: U,
) => ReducerResult<S>;

- export function useAsyncReducer<S,P>(
-  reducers: AsyncReducer<S, P>,
+export function useAsyncReducer<S,P, U>(
+   reducers: AsyncReducer<S, P, U>,
    initialState: S,
+   useCases: U,
): [S, Dispatch<P>] {

    const [state, setState] = useState<S>( initialState )
-   const paramsRef = useRef<[AsyncReducer<S, P>,]>([reducers]);
+   const paramsRef = useRef<[AsyncReducer<S, P, U>, U]>([reducers, useCases]);
    const stateRef = useRef<{state:S, setState:Dispatch<SetStateAction<S>>}>({ state, setState});
    const dispatcherRef = useRef<Dispatch<P> | null>(null);

    if ( !dispatcherRef.current ) {
        dispatcherRef.current = (params: P) => {
-           const [reducers] = paramsRef.current;
+           const [reducers, useCases] = paramsRef.current;
            const {state} = stateRef.current;
-           handleResult(reducers(state, params), stateRef);
+           handleResult(reducers(state, params, useCases), stateRef);
        }
    }
-   paramsRef.current = [reducers];
+   paramsRef.current = [reducers, useCases];
    stateRef.current = { state, setState};
    return [state, dispatcherRef.current];
}
UseCasesの定義

ビジネスルールを記述します。interface として抽象化しておくことで、Reducerとの疎結合が保たれます。
今回は歩行者信号しかないので、押しボタン押下時のリクエストのみ記述します。

src/domain/use_cases/IntersectionUseCases.ts
export interface IntersectionUseCases {
    crossingRequest(): Promise<PedestrianSignalState>;
}
UseCasesの実装

今回はサーバーに問合せをせず、スリープを使って意図的に非同期にしているだけなので、「MockIntersectionInteractor」としました。

※今回のようなサンプルやテスト目的だけでなく、実際の開発時にもMockを用意しておくとバックエンドと分業しやすかったり、画面の開発だけ先行させることも出来ますし、画面が先行するとエンドユーザーとのイメージ共有のハードルが下がるため、結構実用的です。

src/interactiors/MockIntersectionInteractor.ts
export class MockIntersectionInteractor implements IntersectionUseCases {
    async crossingRequest(): Promise<PedestrianSignalState> {
        await sleep(3000);
        return PedestrianSignalStates.Blue;
    }
}
Reducers

Reducersにも手を加えていきます。

src/presenters/IntersectionReducers.ts
- export type IntersectionReducers = AsyncReducer<IntersectionViewState, IntersectionAction>;
+ export type IntersectionReducers = AsyncReducer<IntersectionViewState, IntersectionAction, IntersectionUseCases>;

export type IntersectionReducer = (
    state: IntersectionViewState,
    params: IntersectionAction["params"],
+   useCases: IntersectionUseCases
) => ReducerResult<IntersectionViewState>;

- export const Reducers: IntersectionReducers = (state, action) => {
+ export const Reducers: IntersectionReducers = (state, action, useCases) => {

    switch(action.type) {
        case "crossingRequest":
-           return CrossingRequestReducer(state, action.params);
+           return CrossingRequestReducer(state, action.params, useCases);
        default:
-           return ResetReducer(state, action.params);
+           return ResetReducer(state, action.params, useCases);
    }
}

スリープ処理を削除し、信号機へのリクエストはUseCasesに委譲します。

src/presenters/reducers/CrossingRequestReducer.ts
+ export const CrossingRequestReducer: IntersectionReducer = async function* (state,params, useCases) {
- export const CrossingRequestReducer: IntersectionReducer = async function* (state) {
    yield {
        ...state,
        waiting: true
    };
-   await sleep(3000);
    yield {
        ...state,
-       pedestrianSignal: PedestrianSignalStates.Blue,
+       pedestrianSignal: await useCases.crossingRequest(),
        waiting: false
    }
}
コンテナ部

最後に依存性の注入を行う App.tsx を修正します。

src/App.tsx
+ const USE_CASES = new MockIntersectionInteractor();
function App() {
    const [state, dispatcher] = useAsyncReducer(
        Reducers,
        {pedestrianSignal: PedestrianSignalStates.Red, waiting: false },
+       USE_CASES
    );
    :
    :

↑までのコードはココに上げてあります。

Mapped types と Lookup tableを用いたストラテジーパターンの実現

せっかく TypeScript を使っているので、カスタムフックに手を加えてReducerの型チェックをもう少し厳密に出来るようにします。
また、個人的にはReducerのSwitch文がどうしても好きになれないので、TypeScriptではある程度おなじみのLookup table を使った ストラテジーパターン を実装してSwitch文を消したいと思います。

カスタムフックを以下のように修正します。
受け取ったreducersのマップを Object.keys でループした後、reduce 関数で ActionDispatcher に変換しています。

ActionDispatcherは 総称型<A> で定義したActionsのキーをキーとして持ち、値にパラメタを受け取るVoid関数を持つオブジェクトです。

src/shared/UseAsyncReducer.ts
+export type AsyncReducers<S, A, U> = {
+   [P in keyof A]: AsyncReducer<S, A[P], U>
+};
+export type ActionDispatcher<A> = {
+   [P in keyof A]: A[P] extends undefined ? () => void : ( params: A[P] ) => void
+};

- export function useAsyncReducer<S,P, U>(
+ export function useAsyncReducer<S, A, U>(
-   reducers: AsyncReducer<S, A, U>,
+   reducers: AsyncReducers<S, A, U>,
    initialState: S,
    useCases: U,
- ): [S, Dispatch<P>] {
+ ): [S, ActionDispatcher<A>] {

    const [state, setState] = useState<S>( initialState )
-   const paramsRef = useRef<[AsyncReducer<S, P, U>, U]>([reducers, useCases]);
+   const paramsRef = useRef<[AsyncReducers<S, A, U>, U]>([reducers, useCases]);
    const stateRef = useRef<{state:S, setState:Dispatch<SetStateAction<S>>}>({ state, setState});
-   const dispatcherRef = useRef<Dispatch<P> | null>(null);
+   const dispatcherRef = useRef<ActionDispatcher<A> | null>(null);

+   if ( !dispatcherRef.current ) {

-       dispatcherRef.current = (params: P) => {
+       dispatcherRef.current = Object.keys(reducers).reduce(( res, actionName ) => {
-           const [reducers, useCases] = paramsRef.current;
+           const reducers = paramsRef.current[0];
+           const reducer = reducers[actionName as keyof A];
+           res[actionName as keyof A] = ((params: any) => {
+               const useCases = paramsRef.current[1]
                const {state} = stateRef.current; //
-               handleResult(reducers(state, params, useCases), stateRef);
+               handleResult(reducer(state, params, useCases), stateRef);
+           }) as any;
+           return res;
+       }, {} as ActionDispatcher<A>);
-       }
    }
    paramsRef.current = [reducers, useCases];
    stateRef.current = { state, setState};
    return [state, dispatcherRef.current!];
}

Mapped types による型チェックについて少し補足します。

ActionDispatcherは以下のように定義されていますが、

export type ActionDispatcher<A> = {
    [P in keyof A]: A[P] extends undefined ? () => void : ( params: A[P] ) => void
};

以下のようなDispatcherを用意した場合

export type HogeHogeDispatcher = ActionDispatcher<{
    foo: undefined, 
    bar: {
        text: string
    },
    baz: number
}>

エラー判定は下記のようになります。


function(dispatcher: HogeHogeDispatcher) {
    dispatcher.foo() // OK
    dispatcher.foo(1) // NG
    dispatcher.bar({text: "a"}) // OK
    dispatcher.bar({text: 2}) // NG
    dispatcher.baz() // NG
    dispatcher.foobar() // NG
}

Mapped typesの適用

修正したカスタムフックの定義に合わせて、アプリケーションに型定義を組み込んでいきます。

アクションの定義

パラメタの型チェックが確認出来るよう若干変更。
crossingRequest は歩行書ボタンに表示するメッセージを属性として持つオブジェクト。
reset はパラメタ無しとします。

TypeScriptの Mapped types により、ここでの型定義がReducerやComponentにおけるdispatcherの呼び出しにおける型チェックに効いてきます。

意外と手間の掛かる定数定義も省けます

src/presenters/actions/IntersectionActions.ts
export interface IntersectionActions {
-   crossingRequest: any;
+   crossingRequest: { message: string };
-   reset: any;
+   reset: undefined;
}

- export interface IntersectionAction  {
-   type: keyof IntersectionActions;
-   params?: any
- }
ステートの定義

歩行者ボタンに表示させるメッセージをステートとして追加します。

presenters/IntersectionViewState.ts
export interface IntersectionViewState {
    pedestrianSignal: PedestrianSignalState;
    waiting: boolean;
+   message?: string;
}
Reducers

IntersectionReducers を Mapped typesによる型チェック付きのLookup tableにすることで、
Reducerの過不足と型チェック出来るようになり、Switch文も無くなります。

presenters/IntersectionReducer.ts
- export type IntersectionReducers = AsyncReducer<IntersectionViewState, IntersectionAction, IntersectionUseCases>;
+ export type IntersectionReducers = AsyncReducers<IntersectionViewState, IntersectionActions, IntersectionUseCases>;

- export type IntersectionReducer = (
+ export type IntersectionReducer<K extends keyof IntersectionActions> = (
    state: IntersectionViewState,
-   params: IntersectionAction["params"],
+   params: IntersectionActions[K],
    useCases: IntersectionUseCases
) => ReducerResult<IntersectionViewState>;

- export const Reducers: IntersectionReducers = (state, action, useCases) => {
-   switch(action.type) {
-       case "crossingRequest":
-           return CrossingRequestReducer(state, action.params, useCases);
-       default:
-           return ResetReducer(state, action.params, useCases);
-   }
- }
+ export const Reducers: IntersectionReducers = {
+   crossingRequest: CrossingRequestReducer,
+   reset: ResetReducer
+ };
CrossingRequestReducer

更新対象のステートにmessageを加えます。

presenters/reducers/CrossingRequestReducer.ts
- export const CrossingRequestReducer: IntersectionReducer = async function* (state,params, useCases) {
+ export const CrossingRequestReducer: IntersectionReducer<"crossingRequest"> = async function* (state,params, useCases) {
    yield {
        ...state,
        waiting: true,
+       message: params.message
    };
    yield {
        ...state,
        pedestrianSignal: await useCases.crossingRequest(),
        waiting: false,
+       message: undefined
    }
}
ResetReducer

こちらは型定義のみを更新します。

presenters/reducers/ResetReducer.ts
- export const ResetReducer: IntersectionReducer = (state) => {
+ export const ResetReducer: IntersectionReducer<"reset"> = (state) => {
コンテキスト

Contextの修正です。下記のように初期値を真面目に設定するとやや面倒ですが、
現状の仕様では直ぐ上書きされるので、空オブジェクトをanyにキャストして逃げても問題ありません。

presenters/contexts/IntersectionContext.ts
export const IntersectionContext = createContext<{
    state: IntersectionViewState,
-   dispatcher: Dispatch<IntersectionAction> }>(
+   dispatcher: ActionDispatcher<IntersectionActions> }>(
    {
        state: { pedestrianSignal: PedestrianSignalStates.Red, waiting: false },
-       dispatcher: () => {}
+       dispatcher: Object.keys(Reducers).reduce((res, key) => {
+           res[key as keyof IntersectionActions] = () => {};
+           return res;
+       }, {} as ActionDispatcher<IntersectionActions>)
    }
);
Components

コンポーネントの修正です。型付きLookup table により、typeで指定していた箇所が定義済み関数として呼び出せるようになります。(モチロンIDEによる補完も効きます

アクションの数が増えてきてもAction Creatorとかを別途用意する必要はないかと思います。

src/components/Intersection.tsx
-                dispatcher( { type:"reset"});
+                dispatcher.reset();
src/components/PedestrianButton.tsx
-            <text x="70.5" y="39" textAnchor="middle" fontSize="12" fill={ state.waiting ? "red" : "none"}>おまちください</text>
+            <text x="70.5" y="39" textAnchor="middle" fontSize="12" fill="red">{ state.message }</text>
                    :
                    :
-                dispatcher( { type:"crossingRequest"});
+                dispatcher.crossingRequest({ message: "おまちください"});

最終的なコードはココに上げてあります。

まとめ

この記事で色々と記述してきましたが、
外部フレームワークに依存しないステート管理は、実際のところやってみれば分かるのですが カスタムフック一つで大体解決 出来てしまいます。

とはいえ、明確なレールが引かれてる訳ではないため、とっかかりにくい面は多々あると思います。
それでも、一度理解してしまえば外部フレームワークの制約に縛られずカスタムフックをメンテナンスすることができ、プロジェクト毎の ローカルルールに最適化したセキュアなオレオレフレームワークを短時間で構築 することが可能です。

参考文献

5
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
5