はじめまして。16卒で入社したエンジニアの渡部です。
現在は転職会議のプロダクト開発グループに所属していて、最近は会員情報ページのフロントエンド開発を行っています。
今回はReact化にあたって直面した問題、それを解決するために採用・参考にした技術を幾つか紹介したいと思います。
コード例はTypeScriptで書いています。
目次
- 型を欲する声の高まり(TypeScript)
- action層の肥大化(redux-observable)
- formのvalidation(Computing Derived Data(reselect))
- viewをシンプルに保つ(High Order Components)
型を欲する声の高まり(TypeScript)
react-reduxで実装していると関数から関数へと値を引き回すので、型をつけて安心して実装したいという声がチーム内で高まってきました。
そこでTypeScriptを導入することにしました。
Flowtypeも有名ですが、TypeScriptを導入した理由は以下のとおりです。
- チームに有識者がいた
- vscodeの補完機能が魅力的だった。
- jsファイルからの移行もany型を使ってそれほど苦なくできそうだった。
基本的にスムーズにjsからtsへの移行を進めることができましたが、いくつか詰まった点もあります。
Array.prototype.filterのtype predicateが効かない
type HogeArray = (string | number)[]
をtype FugaArray = number[]
にfilterを使って変換します。
const hoge: HogeArray = [1, "2", 3, "4", 5] const fuga: FugaArray = hoge.filter((el): el is number => typeof el === "number")
しかし、type predicate(el): el is number => typeof el === "number"
が効かず、hoge.filter((el): el is number => typeof el === "number")
の型はtype HogeArray = (string | number)[]
のままなので、上記コードはコンパイルで落ちます。
解決策としてはこんなのがあります。
https://github.com/Microsoft/TypeScript/issues/7657
lodashとの相性が悪い
lodashについては色々あるのですが、ここではmapValuesを例に挙げたいと思います。
type Profile = { name: string age: number } type Profiles = { [id: number]: Profile }
Profiles型のobjectにmapValuesを適用して各Profileをnameで置き換えます。 結果は以下のような型になります。
type Names = { [id: number]: string }
しかしながらlodash/mapValuesには、処理するvalueの型が引数と返り値で一致するように型がつけられています。 上述の処理では引数のvalueの型がProfile、返り値のvalueの型がstringなので、コンパイルエラーになってしまいます。
lodashはこのように型の指定がうまくいかないことがわりとよくあります。
action層の肥大化(redux-observable)
action層の肥大化をredux-observableを導入して解消したお話です。
各所での採用実績を参考に私達はreduxを採用しています。
当初ミドルウェアにredux-thunkを採用していたので、apiとの通信といった非同期処理はaction層に書いていました。
こんな感じです。
// 通信の開始 function startConnection(connectionId: string){ return { type: "START_CONNECTION", meta: { connectionId } } } // 通信の終了 function finishConnection(connectionId: string){ return { type: "FINISH_CONNECTION", meta: { connectionId } } } // Error処理 function handleFetchError(error: string){ return { type: "HANDLE_FETCH_ERROR", payload: { error } } } // 取得した値をstateに反映する function fulFilledProfile(profile){ return { type: "FULFILLED_PROFILE", payload: { profile } } } // mainの関数 function fetchProfile(uuid: string){ return (dispatch) => { dispatch(startConnection(uuid)) axios.get("/api/profile") .then(({data}) => dispatch(fulFilledProfile(data.profile))) .then(() => dispatch(finishConnection(uuid))) .catch((e) => dispatch(handleFetchError(e))) } }
action層に単純なobjectを返す関数と関数を返す関数が混在していて、扱いづらくなっています。
async await構文を使うと見た目はスッキリしますが、action層にaction creator以外の関数が混じってしまう問題は解決しません。
// mainの関数 function fetchProfile(uuid: string){ return async (dispatch) => { try { dispatch(startConnection(uuid)) const {data} = await axios.get("/api/profile") dispatch(finishConnection(uuid)) } catch(e) { dispatch(handleFetchError(e)) } } }
action層からこうした複雑性を逃がすために、私達はredux-observableを採用しました。
非同期処理やビジネスロジックを逃がすためのミドルウェアとして他にredux-sagaやredux-logicがあります。
私達がredux-observableを選んだ理由は以下のとおりです。
- チーム内でRxJSへの関心が高まった。
- コードベースが少なかった。
ただ、RxJSをあちこちで使ってしまうとかえってコードの複雑さが増してしまうので、redux-observableのepic内にその使用を限定することにしました。
ではさっきのactionを書き直してみます。
//action // 通信の開始 function startConnection(connectionId: string){ return { type: "START_CONNECTION", meta: { connectionId } } } // 通信の終了 function finishConnection(connectionId: string){ return { type: "FINISH_CONNECTION", meta: { connectionId } } } // Error処理 function handleFetchError(error: string){ return { type: "HANDLE_FETCH_ERROR", payload: { error } } } // epic層に配置した非同期処理をキックする function fetchProfile(connectionId: string){ return { type: "FETCH_PROFILE", meta: { connectionId } } } // 取得した値をstateに反映する function fulFilledProfile(profile){ return { type: "FULFILLED_PROFILE", payload: { profile } } }
//epic const startConnection$ = (action$) => ( acton$ .map(({meta}) => startConnection(meta.connectionId)) ) const finishConnection$ = (action$) => ( acton$ .map(({meta}) => finishConnectino(meta.connectionId)) ) const fetch$ = (action$) => ( action$ .switchMap(() => api.get("/api/profile")) .map(({data}) => fulfilledProfile(data.profile)) ) const fetchProfileEpic$ = (action$) => ( action$.typeOf("FETCH_PROFILE") .mergeMap((action) => Observable.concat( startConnection$(action$), fetch$(action$) .catch((e) => handleFetchError$(e)), finishConnection$(action$) )) )
Epicについては簡略化したところがありますが、こんな感じです。
非同期処理や通信状態を管理するためのロジックをaction層から切り離すことができました。
formのvalidation(Computing Derived Data(reselect))
私たちはstateとviewを以下のような方針の下構成しています。
- stateから重複等無駄な値を排除する
- viewにロジックを持たせない
この2つのルールは相反する部分があるのですが、それをuserの平均年収を表示するアプリケーションを例に考えてみます。
viewにはロジックをもたせたくないので、平均年収はpropsとしてコンポーネントの外から与えられなければなりません。
しかし、stateにはuserそれぞれの年収を既に持っているので、平均年収を持つと情報が重複してしまいます。
viewで計算はしたくない、stateにも持たせたくない。
よってviewに渡す前にstateの値を再計算することになります。
Computing Derived Data
は、stateとviewの間にselector層を設けてstateを再計算し、その結果をviewに渡す実装パターンです。
selectorはstateを受け取って計算を行い、新たな値を返す関数です。
実装例は下のようになります。
私たちはreselect
を使ってselectorを定義しています。
// state type User = { name: string salary: number } type State = { users: User[] } // selector // reselect/createSelectorをつかった場合 const averageSalarySelector = createSelector<State, User[], number>( (state) => state.users, (users) => users.map(({salary}) => salary) .reduce((prev, curr) => prev + curr) / users.length ) // reselectを使わない場合 function averageSalarySelector({users}: State): number { return users.map(({salary}) => salary) .reduce((prev, curr) => prev + curr) / users.length } // view const AverageSalaryBase: React.SFC<{averageSalary: number}> = ({averageSalary}) => ( <div>平均年収: {averageSalary} 万円</div> ) const mapStateToProps = (state) => ({ averageSalary: averageSalarySelector(state) }) const AverageSalary = connect(mapStateToProps)(AverageSalaryBase)
さて、formのvalidationですが、validation errorもstateから計算可能な値です。 私たちはerrorをstateに持たせるのではなくselectorを使って計算してviewに渡すことにしました。
氏名編集フォームの例を見てみましょう。
ここでは、氏名が未入力のときにvalidation errorを表示するように実装します。
// state type State = { profile: { name?: string } } // validator const namePresenceValidator = createValidator<State, string | undefined, boolean>( (state) => state.profile.name, (name) => typeof name !== undefined ) // view const NameFormBase: React.SFC<{name: string, isValid: boolean}> = ({name="", isValid}) => ( <div> { !isValid ? "氏名を入力してください" : <noscript /> } <input type="text" value={ name } /> </div> ) const mapStateToProps = (state) => ({ name: state.profile.name, isValid: namePresenceValidator(state) }) const NameForm = connect(mapStateToProps)(NameFormBase)
stateを再計算するロジックを単独で切り出すことで、テストもしやすくなりメンテナンス性も向上しています。
viewをシンプルに保つ(High Order Components)
前述の通り私達は極力viewをシンプルに保つようにしています。
とはいえviewからロジックを完全に排除するのは難しいので、関数として切り出して共通化することでcomponentをシンプルに保ちます。
例えば先の名前編集フォームを見てみましょう。
// view const NameFormBase: React.SFC<{name: string, isValid: boolean}> = ({name="", isValid}) => ( <div> { !isValid ? "氏名を入力してください" : <noscript /> } <input type="text" value={ name } /> </div> )
ここではisValid
の値によってエラーの表示・非表示を切り替えています。
こんな感じでviewの表示・非表示を切り替えたいということはよくあるので関数として切り出すことにします。
type Component<T> = React.SFC<T> | React.ComponentClass<T> function switchVisibility<T>( predicate: (props: T) => boolean, LeftComponent: Component<T>, RightComponent: Component<T> ): Component<T> { return (props) => predicate(props) ? <LeftComponent { ...props } /> : <RightComponent {...props } /> }
この関数はComponentを受け取ってComponentを返すHigh Order Components(HoCs)
です。
HoCsを適宜使うことでviewが持つロジックを共通化することができ、またその関数単独でテストしやすくなります。
switchVisibility
関数で先のエラー表示コンポーネントを実装してみます。
type PropsType = { message: string isValid: boolean } const ErrorMessageBase: React.SFC<PropsType> = ({message}) => ( <div>{ message }</div> ) const ErrorMessage = switchVisibility<PropsType>( ({isValid}) => !isValid, ErrorMessageBase, () => <noscript /> )
HoCsを使うのに便利なライブラリとして、recomposeがあります。
Readmeによれば、React Componentのためのlodashのような存在だということです。
switchVisibility関数をrecompose/branchを使って実装してみます。
使い方はこんな感じです。
const switchVisibility = branch( ({isValid}) => !isValid, (component) => component, renderNothing ) const ErrorMessage = switchVisibility(ErrorMessageBase)
結び
以上羅列的にではありますが、私達がReact化にあたって使った技術を紹介させて頂きました。
React化の事例は数ありますが、本記事が読者さま方の参考になれば幸いです。