- 投稿日:2020-05-20T23:08:52+09:00
Reactのコンポーネントライフサイクルを知ろう
目次
概要
この記事は、現在Reactを勉強中である私がReactのライフサイクルについての理解を深めるためにまとめたものです。私と同じようにReactを勉強中の人にとってこの記事が手助けとなるようであれば幸いです。
Reactの基礎の基礎については別の記事にまとめています。本記事は最低限必要な基礎を既に学習済みの方向けの内容となっています。Reactのコンポーネントライフサイクルとは
Reactのコンポーネントにはライフサイクルというものが存在します。私が色々と調べている中でわかりやすいと感じた例が「日の出から日の入り」です。
コンポーネントのライフサイクルはMounting、Updating、Unmountingという3つの状態を遷移しています。
この3つの状態と上記の例を対応付けると、以下の表のようになります。
状態 時間帯 説明 Mounting 日の出前 コンポーネントのインスタンスが作成され、DOMに挿入されるとき。レンダリングの準備期間。 Updating 日中 propsやstateの変更によりコンポーネントが再レンダーされるとき。ユーザが操作可能な期間 Unmounting 日の入り後 コンポーネントがDOMから削除されるとき。 コンポーネントのライフサイクルでは、各状態に応じて仕様できるライフサイクルメソッドというものが用意されています。
ライフサイクルメソッドの詳しい説明についてはこちらをご覧ください。
(これらはクラスコンポーネントで使用できるメソッドですが、React v16.8でHooksのuseEffect()
を用いて関数コンポーネントでも一部代用できるようになりました。)Mounting
constructor ()
static getDrivedStateFromProps ()
render ()
componentDidMount ()
Updating
getDrivedStateFromProps ()
shouldComponentUpdate ()
render ()
getSnapshotBeforeUpdate ()
componentDidUpdate ()
Unmounting
componentWillUnmount ()
ライフサイクルメソッドの説明
ここでは、先ほど紹介したライフサイクルメソッドを使用頻度の多い順に説明をします。
よく使うライフサイクルメソッド
render()
使用できる状態: Mounting / Updating
- クラスコンポーネントで必ず定義しなければならないメソッド
- propsやstateが更新されるたびに呼ばれる
- 純粋関数である必要がある:render()内でpropsやstateなどを直接操作してはいけない
- 返す要素は単一:要素全体を一つのタグで囲む(
<div>
や<React.Fragment>
など)
constructor()
使用できる状態: Mounting
- Mounting時に一番最初に呼ばれるメソッド
React.Component
のサブクラスで実装するときにはsuper(props)
を呼び出す必要があるthis.state
を初期化するときに使用- イベントハンドラをインスタンスにバインドするときに使用
componentDidMount()
使用できる状態: Mounting
- コンポーネントがマウントされた直後に一度だけ呼び出される(1度目の
render()
後)- DOMに関する初期化処理をしたいときに使用(直接DOM操作などはしないこと)
- ネットワークへのリクエストなどを行うときに使用
setTimeout()
などはここで行う
componentDidUpdate()
使用できる状態: Updating
- コンポーネントの更新が行われたタイミングで呼び出される
- 第一引数にprevProps(直前のprops)、第二引数にprevState(直前のstate)を受け取る
- 第三引数にsnapshotをとる場合もある(後述の
getSnapshotBeforeUpdate()
の返り値)
componentWillUnmount()
使用できる状態: Unmounting
- 現在のコンポーネントが破棄(アンマウント)される直前で呼ばれるメソッド
- タイマーの無効化、ネットワークへのリクエストのキャンセルなどを行う
- 必要ない設定を破棄することでメモリを開放できる
- この後に
render()
が呼ばれることはないまれに使うライフサイクルメソッド
static getDrivedStateFromProps()
使用できる状態: Mounting / Updating
- propsの値によってstateに更新があるかチェックするメソッド
- Mounting時は
render()
の直前、Updating時は一番最初に呼ばれる- 第一引数にprops、第二引数にstate
- stateがpropsに依存するというなまれな場合に使用
- 更新があれば更新後の
state
、ない場合はnull
を返す
shouldComponentUpdate()
使用できる状態: Updating
- 新たなstateやpropsを受け取った場合に
render()
の直前で呼び出されるメソッド- 第一引数にnextProps(更新後のprops)、第二引数にnextState(更新後のstate)
true
またはfalse
を返す- デフォルトは
true
を返し再レンダリングをするが、false
が返った場合は更新がスキップされる
getSnapshotBeforeUpdate()
使用できる状態: Updating
render()
後componentDidUpdate()
が呼び出される直前で呼び出されるメソッド- コンポーネントが変更される可能性があるとき、スクロール位置などの情報を取得することができる
- 返り値は
componentDidUpdate()
の第三引数に渡されるまとめ
今回はReactのコンポーネントライフサイクルについてまとめてみました。
Reactを扱う上でよく使うrender()
メソッドもライフサイクルメソッドの一つであるということを抑えておきましょう。
それ以外にもconstructor()
やcomponentDidMount()
などよく登場するメソッドについては理解しておくと良いかと思います。参考資料
- 投稿日:2020-05-20T19:23:54+09:00
Reactで一番下までスクロール
ちょっとしたことですが、以下のようなスクロールでちょっと詰まったので覚書程度に。。
やりたいこと
コンポーネント内でボタンを押す
↓
アコーディオンが開く
↓
指定位置までスクロールするスクロールなかったら下にちょろっと出てくるだけなのであんまりよろしく無いかな〜と思いやってみました。
アコーディオン開くまで
App.jsconst App = () => { const [isOpen, setIsOpen] = useState(false); const changeIsOpen = () => { setIsOpen(!isOpen); }; return ( <div className="App"> <div className="wrapper"> <button onClick={changeIsOpen}>Click Me!</button> <ul id="target" className={isOpen ? "ul open" : "ul close"}> <li>list1</li> <li>list2</li> </ul> </div> </div> ); }; export default App;(styled-component使ってましたがとりあえず参考に…)
style.css.ul { margin: auto; list-style: none; max-height: 0; overflow: hidden; } .open { max-height: 500px; transition: max-height 0.5s; } .close { max-height: 0; transition: max-height 0.5s; }試したこと1
const App = () => { const [isOpen, setIsOpen] = useState(false); const changeIsOpen = () => { setIsOpen(!isOpen); const target = document.getElementById("target"); target.scrollIntoView({ behavior: "smooth", block: "end" }); }; return ( <div className="App"> <div className="wrapper"> <button onClick={changeIsOpen}>Click Me!</button> <ul id="target" className={isOpen ? "ul open" : "ul close"}> <li>list1</li> <li>list2</li> </ul> </div> </div> ); }; export default App;onClickの時に一緒に処理しようと思ったけどisOpenが変わる前に処理が走ってしまうので上手くいかなかった。
試したこと2
そりゃそうか…と思いながらuseEffectを使いましたがまだちゃんと動かない…
const App = () => { const [isOpen, setIsOpen] = useState(false); const changeIsOpen = () => { setIsOpen(!isOpen); }; useEffect(() => { const target = document.getElementById("target"); if (target) { target.scrollIntoView({ behavior: "smooth", block: "end" }); } }, [isOpen]); return ( <div className="App"> <div className="wrapper"> <button onClick={changeIsOpen}>Click Me!</button> <ul id="target" className={isOpen ? "ul open" : "ul close"}> <li>list1</li> <li>list2</li> </ul> </div> </div> ); }; export default App;原因はCSSのtransitionでした。
transitionで0.5s指定しているので、アコーディオンが開く前にjs動いていたようです。解決
アコーディオンの要素に
addEventListener
でtransitionend
をとってスクロールを動かしました。useEffect(()=>{ const target = document.getElementById("transitionTarget") if (target){ target.addEventListener("transitionend",()=>{ target.scrollIntoView({ behavior: "smooth", block: "end" }); }) } },[isOpen])サンプル
https://codesandbox.io/s/transition-l0o2z?file=/src/App.js
- 投稿日:2020-05-20T01:08:34+09:00
ReactHooksざっくりまとめ
はじめに
Hooksを触り始めている今日この頃。
雰囲気で色々触っていたけど、だんだん辛くなってきたのでちゃんとまとめていこうと思う。
APIリファレンスを見ながら、簡単に使い方とサンプルをまとめておく。TL;DR.
useState
component内で使うステートフルな値と、それを更新するための関数を返す。
Class Componentのstateと同じ。使い方
import { useState } from 'react'; // useStateに渡した値がstateの初期値になる const [state, setState] = useState(initialState); // stateの値を更新したいときはsetStateを使う setState(updateState); // 型指定したい場合、useStateの際に指定できる const [stringState, setStringState] = useState<string>(initialState); // 初期化を遅延させることもできる const [lagyState, setLagyState] = useState(() => { // 初期値をなにかしら受け取る const initialState = getHoge(); return initialState; });サンプル
import React, { useState } from 'react'; export const StateComponent: React.FC<any> = () => { // useStateを使う const [greetingMessage, setGreetingMessage] = useState<string>('hello'); return ( <> {/* stateの内容を表示 */} <div>{greetingMessage}</div> <form> {/* stateの内容を更新 */} <input type="text" onChange={text => setGreetingMessage(text.target.value)} /> </form> </> ) };useEffect
副作用を有する可能性のある命令型コードを受け付ける。
ClassComponentで言うところのcomponentDidMount
やcomponentDidUpdate
の置き換え。
って言われているけど実際には近しい処理くらいが正しい気がする。
※componentDidMount
やcomponentDidUpdate
とは別物の認識。使い方
// これだとレンダリングの度に毎回実行される useEffect(() => { // 副作用がある処理を行う const subscription = props.source.subscribe(); // クリーンアップ処理を行いたい場合は、useEffectに渡す関数の戻り値に関数を渡せば良い return () => { subscription.unsubscribe(); }; }); // 副作用が依存している値を第2配列に指定する // props.sourceに変更があったときのみ実行する useEffect(() => { const subscription = props.source.subscribe(); return () => { subscription.unsubscribe(); }; }, [props.source]); // 一度だけ実行したい場合△ useEffect(() => { const subscription = props.source.subscribe(); return () => { subscription.unsubscribe(); }; // 第2引数に空配列を渡してあげれば良い // 空配列を渡すことで、何にも依存しない(2回目が実行されることない)ことを伝える // 処理としてはprops.sourceに依存するため、渡して上げるのが良い }, []); // 一度だけ実行したい場合◎ useEffect(() => { function doSomething() { console.log(someProp); } doSomething(); }, [someProp]);サンプル
export const EffectComponent: React.FC<any> = () => { const [count, setCount] = useState(0); // useEffectを使う useEffect(() => { const interval = setInterval(() => setCount(count + 1), 1000); // intervalをリセットする return () => clearInterval(interval); // このeffectはcountに依存しているので、countを第二引数に渡す // →ここを渡さないとeffect無いではstateが初期値から変わらない }, [count]); return <div>{count}</div> };useContext
コンテクストオブジェクトを受け取り、そのコンテクストの現在値を返す。
コンテクストの現在値は、ツリー内でこのフックを呼んだコンポーネントの直近にある<MyContext.Provider>
の value の値によって決定される。ざっくり孫コンポーネントなど階層が離れているコンポーネントに値を渡せるようになる認識。
(バケツリレーをしなくても良くなる)いつ使うのが良いかは公式ドキュメントを読むのが良さそう。
使い方
const themes = { light: { foreground: "#000000", background: "#eeeeee" }, dark: { foreground: "#ffffff", background: "#222222" } }; const ThemeContext = React.createContext(themes.light); // 型を指定したければ下記のようにする type ThemeColor = { foreground: string; background: string; } const ThemeContext = React.createContext<ThemeColor>(themes.light); export const App = () => ( // ここでcontextの現在値がdarkになる <ThemeContext.Provider value={themes.dark}> <Toolbar /> </ThemeContext.Provider> ); export const AppInitial = () => ( // 何も渡さなければ初期値(themes.light)になる <Toolbar /> ); const Toolbar = props => ( <div> <ThemedButton /> </div> ); const ThemedButton = () => { const theme = useContext(ThemeContext); return ( <button style={{ background: theme.background, color: theme.foreground }}> I am styled by theme context! </button> ); }サンプル
import React, {useContext} from 'react'; // Contextを作成 const MemoContext = React.createContext<string>('initial text.'); export const ContextComponent = () => ( // textの内容を変える <MemoContext.Provider value="memo text."> <MemoArea /> </MemoContext.Provider> ); const MemoArea = () => ( <div> <Text /> </div> ); const Text = () => { const theme = useContext(MemoContext); return ( <span>{theme}</span> ); }useReducer
useState
の親戚。Reduxのreducerを記述できる。
(state, action) => newState
という型のreducer を受け取り、現在のstate
をdispatch
メソッドとペアにして返す。使い方
// initialState const intialState = { count: 0 } // Reducerを作成(stateとactionを受け取る関数) const reducer = (state, action) => { switch (action.type) { case 'increment': return {count: state.count + 1}; case 'decrement': return {count: state.count - 1}; default: throw new Error(); } } // useRedcerは第一引数にreducer、第二引数にinitialStateを渡してあげる // stateとdispatchメソッドが返ってくる(どちらもReduxの使い方とほぼ同じ) const [state, dispatch] = useReducer(reducer, initialState); // 初期化を遅延させたい場合や特定の場合に初期値にさせたい場合、 // 第三引数に初期化関数を渡してあげる const init(initialCount) => ({count: initialCount}); const reducer = (state, action) => { switch (action.type) { case 'increment': return {count: state.count + 1}; case 'decrement': return {count: state.count - 1}; case 'reset': return init(action.payload); default: throw new Error(); } } const Counter = ({initialCount}) => { const [state, dispatch] = useReducer(reducer, initialCount, init); return ( <> Count: {state.count} <button onClick={() => dispatch({type: 'reset', payload: initialCount})}> Reset </button> </> ); }サンプル
import React, {useReducer} from 'react'; type State = {count: number}; const initialState: State = {count: 0}; const add = (num: number) => ({type: 'ADD', payload: num}); const subtract = (num: number) => ({type: 'SUBTRACT', payload: num}); type Action = ReturnType<typeof add | typeof subtract>; const reducer = (state: State, action: Action) => { switch (action.type) { case 'ADD': return {count: state.count + action.payload}; case 'SUBTRACT': return {count: state.count - action.payload}; default: throw new Error(); } } export const ReducerComponent = () => { const [state, dispatch] = useReducer(reducer, initialState); return ( <> Count: {state.count} <button onClick={() => dispatch(subtract(1))}>-</button> <button onClick={() => dispatch(add(1))}>+</button> </> ); }useCallback
メモ化されたコールバックを返す。
第一引数にコールバック関数、第二引数にコールバックが依存している値を配列で渡す。使い方
const memoizedCallback = useCallback( // 第一引数に関数を指定 () => doSomething(a, b), // 第一引数に渡した関数が依存している値(a, b)をリストで渡してあげる [a, b], );サンプル
import React, {useCallback, useState} from 'react'; export const CallbackComponent = () => { const [count, setCount] = useState(0); // コールバックはcountに依存しているので、第二引数にcountを渡してあげる // 渡さないと関数内のcountの値が更新されない const buttonClick = useCallback(() => setCount(count + 1), [count]); return <ButtonComponent count={count} buttonClick={buttonClick}/> } const ButtonComponent = ({count, buttonClick}: {count: number, buttonClick: () => void}) => ( <div> count: {count} <button onClick={buttonClick}>click</button> </div> )useMemo
メモ化された値を返す。
関数の結果を保持することができるので、同じ引数で関数を呼び出した時には中身の処理は実行せず結果だけ返す。
これによりレンダリングの度に重い処理が走らなくて済むようになる。使い方
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);サンプル
あまり良い例を思いつかなかった。
import React, { useMemo } from 'react'; export const MemoComponent = () => { const count = 10000; // 10000回足し算行う const memoValue = useMemo(() => [...Array(count)].reduce((pure) => pure+1, 0), [count]); return <div>{memoValue}</div> }useRef
.current
プロパティがuseRef
を呼び出した渡す値で初期化されたミュータブルなオブジェクトを返す。
返されるオブジェクトはコンポーネントが存在する限り、存在し続ける。多分よく使われるのは、DOMにアクセスする場合に
ref={hogeRef}
みたいな使い方だと思う。
useRef
はref属性を扱うだけではなく、あらゆる書き換え可能な値を保持して多くためにも使える。使い方
const refContainer = useRef(initialValue);サンプル
import React, { useRef, RefObject } from 'react'; export const RefComponent: React.FC = () => { // Dom触るためのrefを作成 const useRefWithDom: RefObject<HTMLInputElement> = useRef<HTMLInputElement>(null); // 値を保持しておくrefを作成 const useRefWithValue = useRef('initial value'); return ( <div> <input type="text" ref={useRefWithDom} /> <button onClick={() => { if (!useRefWithDom.current) { return; } // 変更前('initial value') console.log('useRefWithValue.current: ', useRefWithValue.current); // currentを上書きすることで変更できる useRefWithValue.current = useRefWithDom.current.value; // 変更後('hoge') console.log('useRefWithValue.current: ', useRefWithValue.current); }}>ボタン</button> </div> ); };useImperativeHandle
ref
が使われた時に親コンポーネントに渡されるインスタンス値をカスタマイズするのに使う。
使う時には[forwardRef](https://ja.reactjs.org/docs/react-api.html#reactforwardref)
と一緒に使う。使い方
function FancyInput(props, ref) { const inputRef = useRef(); useImperativeHandle(ref, () => ({ focus: () => { inputRef.current.focus(); } })); return <input ref={inputRef} ... />; } FancyInput = forwardRef(FancyInput);サンプル
import React, { forwardRef, useRef, useImperativeHandle, RefObject } from 'react'; const FancyInput = (props: any, ref: any) => { // refを定義 const inputRef: RefObject<HTMLInputElement> = useRef<HTMLInputElement>(null); // 受け取ったrefにfocus関数を追加する useImperativeHandle(ref, () => ({ focus: () => inputRef?.current?.focus() })); return <input ref={inputRef} />; } // forwardRefに作った関数を渡す(変数に置かなくても問題なし) const FancyInputRef = forwardRef(FancyInput); export const ImperativeHandleComponent: React.FC = () => { // ここで使うrefを定義 const ref: RefObject<HTMLInputElement> = useRef<HTMLInputElement>(null); return (<> {/* refを渡す(このrefにfocus関数が追加される) */} <FancyInputRef ref={ref} /> {/* クリックした時にテキストボックスにフォーカスする */} <button onClick={() => ref?.current?.focus()}>clickでフォーカス</button> </>) };useLayoutEffect
基本的には
useEffect
と同じ。
違う点はDOM の変更があった後で同期的に副作用が呼び出されるところ。
DOMを操作して再描画する場合に使う。
最初はuseEffect
を使って、問題があるときのみuseLayoutEffect
を使う方が良い。と、公式サイトに書いてある。
useDebugValue
React DevTools でカスタムフックのラベルを表示することができる。
使い方
useDebugValue(value)サンプル
const useFriendStatus = (friendID) => { const [isOnline, setIsOnline] = useState(null); // DevToolのラベルが下記のように表示される // "FriendStatus: Online" useDebugValue(isOnline ? 'Online' : 'Offline'); return isOnline; }まとめ
Hooksについてざっくりまとめた。
useState
とかuseEffect
とか基本的なHooksについてはだいぶわかりやすいと思った。
メモ化ができるuseMemo
とuseCallback
については常に使えば良いってわけじゃなさそうなので、使っていくうちに使い所を見極めたい。参考サイト
- 投稿日:2020-05-20T00:32:08+09:00
非同期処理に対応したフレームワークに依存しないTypeScript + Reactステート管理
非同期処理に対応したフレームワークに依存しないTypeScript + Reactステート管理
ReactHooksが使えるようになったことで、ReduxやmobXなどのフレームワークを用いずとも本格的な状態管理がお手軽に出来るようになり、個人的には良く利用しているのですが、周囲ではReduxに押し負け気味なので理論武装の為にも記事にしてみました。
標準機能のみで実装しておけば、やや戦国時代に突入しそうな気配のあるフレームワークの将来を気にする必要も無いのもメリットです。
ステート管理の代表的な課題
他にも色々ありますが、自分がはじめに遭遇した課題は以下の3点でした。
- バケツリレー回避
- いつものやつです。ルート要素で定義した値を孫要素で用いたい場合、「ルート -> 親 -> 孫」と値を手渡しする必要があり、記述が面倒なのは勿論、仕様変更時の修正範囲も多くなります。皮肉にもコンポーネント化を進めて粒度を小さくすればするほどバケツリレーの被害が大きくなるので真っ先に対応する必要があります。
- 非同期処理
- Reactの ※)render関数はコンポーネントを同期的に返却 する仕様のため、バックエンド側とのやりとりによって表示内容を変える場合は、ステート管理側で非同期処理を解決しておく必要があります。Reduxではおなじみの課題です。
- ※) Supense が正式リリースされれば状況は変わると思います。
- 段階的な状態遷移
- Fluxが苦手とする項目です。一定以上の時間を要する処理を行っている間ユーザーの操作を止めておく場合、ローディング画面 やプログレスバーを表示しておく必要が出てくると思います。その際、「ボタン押下前 -> ローディング画面 -> 処理結果反映後」などの 1アクションに対して複数回の画面表示状態遷移 が発生するため、useReducerから作られるStateのみだと実現が難しいです。
useReducerを用いたステート管理
とりあえずの土台となる、課題の解決を隅においた、useReducerによる単純なステート管理の例を考えます。
押しボタンを押下したら歩行者信号が赤から青に変化する単純なものです。
概要は以下になります。
ディレクトリ構成
ディレクトリ構成は以下のようにしました。
※ 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.tsexport const PedestrianSignalStates = { Red: "Red", Blue: "Blue" } as const; export type PedestrianSignalState = typeof PedestrianSignalStates[keyof typeof PedestrianSignalStates];画面表示状態の定義
一つ前で定義した
PedestrianSignalState
型の値を持つオブジェクトを
画面表示状態の型として定義します。src/presenters/IntersectionViewState.tsexport interface IntersectionViewState { pedestrianSignal: PedestrianSignalState; }Actionの定義
Actionについては、押しボタン押下時の要求 、「CrossingRequestAction」と リセット の2つを定義します。
src/presenters/actions/IntersectionActions.tsexport interface IntersectionActions { crossingRequest: any; // 押しボタン時 歩行者への要求 reset: any; // リセット } export interface IntersectionAction { type: keyof IntersectionActions; params?: any }Reducerの定義と実装
Reducerの型定義と実装です。
ここでは、switch文 で種別を判定し、各Action毎のReducerを呼び出します。src/presenters/IntersectionReducer.tsexport 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.tsexport const CrossingRequestReducer: IntersectionReducer = (state) => { return { ...state, pedestrianSignal: PedestrianSignalStates.Blue } }resetの実装です。歩行者信号を赤に戻します。
src/presenters/reducers/ResetReducer.tsexport const ResetReducer: IntersectionReducer = (state) => { return { ...state, pedestrianSignal: PedestrianSignalStates.Red } }Componentの実装
コンポーネントは「Intersection」を親要素として、「PedestrianSignal」と「PedestrianButton」を子として持つ形で、
3ファイルに分けて実装します。それぞれ 「state」 と 「dispatcher」 をプロパティとして受け取ります。
Intersection
本例ではSVGを使って画面を表現。
リセット処理へのリンクのみこのコンポーネントの直下で保持しています。
src/components/Intersection.tsxexport 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.tsxexport 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.tsxexport 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 を呼び出し、 components と presentersの紐付けを行います。
src/App.tsxfunction App() { const [state, dispatcher] = useReducer( Reducers, {pedestrianSignal: PedestrianSignalStates.Red} ); return ( <Intersection state={state} dispatcher={dispatcher} /> ); } export default App;↑までのコードはココに上げてあります。
バケツリレー回避の方法
ここからは最初に代表的な課題を解決していきます。
まずはバケツリレーの回避についてですが、答えは明確で ContextAPI を用いて解決出来ます。はじめに
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に管理したいオブジェクトの初期値を渡してコンテキストが生成できます。
今回は、stateとdispatcherを属性として持つオブジェクトをコンテキストに格納します。
src/presenters/contexts/IntersectionContext.tsexport const IntersectionContext = createContext<{ state: IntersectionViewState, dispatcher: Dispatch<IntersectionAction> }>( { state: { pedestrianSignal: PedestrianSignalStates.Red }, dispatcher:() => {} } );作成したコンテキストに、App.tsx内のuseReducerによって得られた stateとdispatcherを Provider 要素経由で組み込みます。
これによって、子孫コンポーネントではバケツリレーをせずとも useContext を使うことでstateとdispatcherにアクセスすることが出来るようになります。
コンテナ部
src/App.tsxfunction 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.tsxexport 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.tsxexport function PedestrianSignal( - params: { state: IntersectionViewState, dispatcher: Dispatch<IntersectionAction>} ) { - const {state} = params; + const {state} = useContext(IntersectionContext); : : }PedestrianButton
src/components/PedestrianButton.tsxexport function PedestrianButton( - params: { state: IntersectionViewState, dispatcher: Dispatch<IntersectionAction> } ) { - const {state, dispatcher} = params; + const {state, dispatcher} = useContext(IntersectionContext); : :この程度の規模ではあまり恩恵は感じませんが、後々ボディブローのように効いてきます。
↑までのコードはココに上げてあります。
非同期処理の解決方法
useReducerをそのまま使うのは取り回しが悪いので、カスタムフック で useAsyncReducer を定義します。
useReducer を useState を使ったカスタムフックに置き換えています。
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); }); }非同期処理と同期処理を同一のフックで扱う
テキスト入力のタイピングに合わせてステートを変化させたい場合など、Promise を await するのがよろしくないケースは必ず存在します。その際、非同期処理と同期処理でフックを分けるのは使い勝手が悪いので、 同期的な返却値も同じフックで扱えるように 上記をカスタマイズします。
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.tsfunction 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.tsexport 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秒後 歩行者信号が青となるよう実装します。コンテナ部
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.tsfunction 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秒間の待ち時間の間、歩行者ボタン上に 「おまちください」 とテキストが表示されるようにします。まずは表示用ステートに waiting の項目を追加します。
src/presenters/IntersectionViewState.tsexport 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.tsxexport 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にアクセスする形になります。
注入されるビジネスロジックを内包した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.tsexport 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.tsexport interface IntersectionUseCases { crossingRequest(): Promise<PedestrianSignalState>; }UseCasesの実装
今回はサーバーに問合せをせず、スリープを使って意図的に非同期にしているだけなので、「MockIntersectionInteractor」としました。
※今回のようなサンプルやテスト目的だけでなく、実際の開発時にもMockを用意しておくとバックエンドと分業しやすかったり、画面の開発だけ先行させることも出来ますし、画面が先行するとエンドユーザーとのイメージ共有のハードルが下がるため、結構実用的です。
src/interactiors/MockIntersectionInteractor.tsexport 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.tsexport interface IntersectionActions { - crossingRequest: any; + crossingRequest: { message: string }; - reset: any; + reset: undefined; } - export interface IntersectionAction { - type: keyof IntersectionActions; - params?: any - }ステートの定義
歩行者ボタンに表示させるメッセージをステートとして追加します。
presenters/IntersectionViewState.tsexport 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.tsexport 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: "おまちください"});最終的なコードはココに上げてあります。
まとめ
この記事で色々と記述してきましたが、
外部フレームワークに依存しないステート管理は、実際のところやってみれば分かるのですが カスタムフック一つで大体解決 出来てしまいます。とはいえ、明確なレールが引かれてる訳ではないため、とっかかりにくい面は多々あると思います。
それでも、一度理解してしまえば外部フレームワークの制約に縛られずカスタムフックをメンテナンスすることができ、プロジェクト毎の ローカルルールに最適化したセキュアなオレオレフレームワークを短時間で構築 することが可能です。参考文献