React / Redux を実務で使うということは

公開:2020/10/23
更新:2020/10/26
16 min読了の目安(約14400字TECH技術記事 5

こんにちは、すずです
Reactを使い始めて2年半経ち、その間に3つのサービス(SPA)を立ち上げてきました
その経験から、 React や Redux を実務でしっかり使ってく上でのノウハウを紹介していきます

(この記事ではある程度ReactやReduxの記事・ドキュメントを読んだ初学者を対象としています)

フロントエンド、モノを作ったはいいものの、「変更しづらい」「スケールしない」「この作りではパフォーマンスが出ない」ってなることはありませんか? 僕は100%あります

フロントエンドの弱みは、「ググっても情報が出づらい」ことにあると思います
基本的なライブラリの使い方、どんなライブラリが今トレンドなのか等はすぐにヒットしますが、実務で使う上での注意点、落とし穴、細かなテクニックなどは今でもヒットしづらいと思っています

ググるワードがわからない

今すぐ、この時からあなたが気をつけるべきことを、僕の経験を以てすべてお話します
これの記事を読み終わったあなたが、読む前よりも数段レベルアップして頂けるように全力で書きますので、読み逃さないでください

React 編

React を技術選定するということは

まず第一に、パフォーマンスを出しにくいということを理解する必要があります
個人的な印象ですが、Reactは仮想DOMライブラリの中でも「完璧に近い状態に作り上げて初めてパフォーマンスが出る」ものだと思っています

あなたがパフォーマンスを出すに当たり、少なくとも React.memo() useMemo useCallback useEffect もしくは React.PureComponent を理解する必要があります
まずはこのことを頭にいれて技術選定しましょう
(ただし、ページの規模によっては全くパフォーマンスを意識せずとも大丈夫な場合もあります プロジェクトの規模感とご相談ください)

ClassComponent よりも Hooks を使用した FunctionComponent で構成する方がよりパフォーマンスが出るとされていますので、ここでは Hooks を軸にお話していきます

パフォーマンスを出すには

仮想DOM上でのパフォーマンス改善の基本的な考え方ですが、「レンダリング回数を抑える」ことにあります
React では"いつ"レンダリングが行われるかをおおよそ2つに分類できます

  1. コンポーネントのPropsに変更があったとき
  2. 親コンポーネントで再レンダリングが行われたとき

1で再レンダリングされるのは絶対に必要なことなので良いのですが、2の場合は完全に不必要なレンダリングになります
子コンポーネントへの連鎖的な再レンダリングを抑えるためには、 React.memo() をしようして子コンポーネントをラップする必要があります

React.memo() を知る

React.memo() は、レンダリング回数を抑えてくれる機能を提供してくれる関数です
やってることとしては、 memo() でラップしたコンポーネントの再レンダリング前と後のPropsを比較し、コンポーネントの再レンダリングを抑えてくれます

import * as React from 'react'

const Component = React.memo(props => {
    // ここにコンポーネントを書く
})

// or

const Component = props => {
    // ...
}

export default React.memo(Component)

非常に簡単に使用でき、効果は絶大です やらない選択肢はないでしょう

ただ一つ注意として、 props を内部で比較している方法が shallowEqual であることを覚えておかなければなりません
回避策として React.memo() の第二引数に shallowEqual に変わるカスタム比較関数を入れて自分でレンダリングをコントロールすることもできます

const Component = React.memo(
    props => {
        // ...
    },
    (previousProps, nextProps) => {
        // 比較処理
	// 返り値 boolean
	//    true => 再レンダリングしない
	//    false => 再レンダリングする
    }
)

僕はこのやり方は推奨しません それよりも props に入れる値をできるだけプリミティブにし、 shallowEqual のみで正しく動作させるべきです
(この"比較関数"のメンテをし続ける必要があるため)

Props の値をキャッシュする

React.memo() を使ってコンポーネントをラップしたら終わりかと思いきや、全くそうではありません
次は React.memo() でラップしたコンポーネント(以下、メモ化コンポーネントと呼ぶ)に渡す値をキャッシュさせる必要があります

先程も申し上げた通り、メモ化コンポーネントは基本 shallowEqual によって比較されていますので、JSの仕様上全く同じ値でも作り直された値は別物扱いになってしまう可能性があり、意図しない再レンダリングを引き起こす場合があります
特に注意しなければいけないのは、渡す値が以下の場合です

  1. オブジェクト
  2. 関数

オブジェクトをキャッシュする

でかいオブジェクトを生成してそのまま props へ渡してしまうと再レンダリングを引き起こしてしまいます
その場合、メモ化コンポーネントをレンダリングする親コンポーネントでオブジェクトをキャッシュする必要があります

import * as React from 'react'

const Parent = () => {
    const obj = React.useMemo(() => {
        return // キャッシュしたいオブジェクト
    }, [])
    
    return <Child obj={obj} />
}

const Child = React.memo(({ obj }) => {
    // ...
})

このように useMemo を使用すれば半永久的にオブジェクトをキャッシュし、再レンダリングを抑制することができます
しかし、オブジェクトが半永久的に同じ値にしたいことは稀だと思いますので、 useMemo を使用してもあるタイミングでオブジェクトを更新することができます

大抵の場合親コンポーネントから入ってきた props をつかってオブジェクトを生成しメモ化コンポーネントに渡してあげることがほとんどだと思いますので、そちらを例に挙げます

const Parent = ({ val }) => {
    // 第2引数に依存している値を入れると、依存している値が更新されたタイミングで勝手に更新してくれる
    const obj = React.useMemo(() => {
        return { ...val, hoge: '' } // キャッシュしたいオブジェクト
    }, [val]) 
    
    return <Child obj={obj} />
}

const Child = React.memo(({ obj }) => {
    // ...
})

余談ですが、複雑な計算処理をコンポーネント内でする場合、それをuseMemoの中に閉じ込めておくことでレンダリング時の計算をスキップさせることもできます

関数をキャッシュする

こちらも shallowEqual の魔の手から逃れ、安安とパフォーマンスを悪化させてしまう一派になります

オブジェクトをキャッシュさせるのに useMemo を使用しましたが、こちらは useCallback を使用します

const Parent = (props) => {
    const onClick = React.useCallback((_) => {
        props.onClick()
	return
    }, [props.onClick])

    <Child onClick={onClick} />
}

const Child = React.memo(({ onClick }) => {
    reutrn <div onClick={onClick} />
})

使い方や効果は useMemo と全く一緒になります
関数のキャッシュは忘れがちなので気をつけましょう

React で State を管理するのは極力やめよう

React には State を管理する方法がいくつもあり、簡単に使えて便利です
しかし、一歩間違うとそのコンポーネント内でたくさんのロジックが混在し、どんどん取り返しのつかない技術的負債になっていく可能性があります

もちろんプロジェクトの規模感などにもよりますが、僕は極力 Redux を最初から導入しています
高い確率で、最初は小さいプロジェクトでもグロースしていく内に Redux が必要になるレベルの大きさになっていくからです

ですが、逆にReactで管理したほうが良いStateというのもあると思っています
こちらについて詳しく話していきます

InputやButtonなどのピュアDOMに変わる小さなコンポーネント

デザイン上 <Input /><Button /> などの小さなコンポーネントを作ることは多いと思いますが、こういった頻出する基本的なコンポーネントに関してはReactでState管理したほうが良いです
どちらかと言えば、 Redux でState管理するメリットが無いと言ったほうが正しいかも知れません

また、State管理が煩雑化しメンテ不可能になったとしても、これくらい小さなコンポーネントなら「作り直す」という選択肢が取り得るからというのも大きいです
こちらの基準もチームに寄って違うと思いますので、迷ったらチームと相談するべきだと思います

フォーム

フォームもReactで管理したほうが良いとされています
理由は ReduxStyleGuide にて紹介されています
https://redux.js.org/style-guide/style-guide#avoid-putting-form-state-in-redux
ここで言うフォームは、小さな一つのInputというよりも、Submitボタンを含む大きな括りのフォームです

フォームはできるだけ1つのコンポーネントとして区切っておいたほうが良いです
コンポーネントとして独立しておくと、State管理が楽で、 formik などの外部ライブラリも使いやすくなります

フォームをライブラリに頼らずに管理する場合は、 useState よりも useReducer を積極的に使いましょう
これもグロースしてくるとフォームバリデーションやエラーハンドリング、フォーム項目の追加などやることがどんどん増えていくためです
ログインフォームを例に、サンプルコードを載せておきます

const reducer = (state, action) => {
    switch (action.type) {
        case 'changeEmail':
	    return {
                ...state,
		email: action.payload
            }
	case 'changePassword':
	    return {
	        ...state,
		password: action.payload
	    }
	default:
	    return state
    }
}

const changeEmail = payload => ({
    type: 'changeEmail',
    payload
})

const changePassword = payload => ({
    type: 'changePassword',
    payload
})

const initialState = {
    email: string
    password: string
}

const Form = ({ onSubmit: submit }) => {
    const [state, dispatch] = React.useReducer(reducer, initialState)
    
    const onChangePassword = React.useCallback(e => {
        dispatch(changePassword(e.target.value))
    }, [dispatch])
    const onChangeEmail = React.useCallback(e => {
        dispatch(changeEmail(e.target.value))
    }, [dispatch])
    
    const onSubmit = React.useCallback(_ => {
        submit(state)
    }, [submit, state])

    return (
        <form onSubmit={onSubmit}>
	    <input value={email} onChange={onChangeEmail} />
	    <input value={password} onChange={onChangePassword} />
	</form>
    )
}

もうここまできたら外部ライブラリに頼ったほうが絶対に楽ですね

AtomicDesignをやめよう

これについては諸説あり、プロジェクトの規模感にもよります
もしあなたのプロジェクトが数ページを管理するフロントエンドのプロジェクトになるのであれば、Reduxも導入しているでしょう
その場合はAtomicDesignをやめたほうが懸命です

なぜなら、AtomicDesignがワークするということは、React/Reduxの設計パターンに反しているということになるからです
問題となるのは「Pages層しかConnectしてはいけない」というルールにあります
Reduxを真面目にやるとAtomの1ボタンをConnectするなんてザラですし、そうした方がテストが回しやすくなりメンテしやすくなるのです(後のRedux編で詳しく解説)

ただし、これはtoBの一部のシステムを作っている会社に限ったことで、toCのLPなどの複雑なデザインをフロントエンドで再現するという趣旨で使っている場所ではもちろん僕も全く話が違うかなと思ってます
その場合は意を決して、TemplateとOrganismsの間にConnectしても良い層を1つ追加しましよう
Organismsを細かく分け、そちらの層でConnectしていくことでPagesが巨大化することを防ぎ、幾分もマシになると思います

Redux 編

Redux を技術選定するということは

Reduxは知れば知るほど奥が深く、僕もすべてを理解できているわけではありません
ですので、まずはあなたの通勤時間中にReduxStyleGuideを5回読みましょう リモートワークの方は朝のコーヒータイムの時に毎日読みましょう 1週間で5回読めますよ
https://redux.js.org/style-guide/style-guide

Redux完全理解を目指すにはバックにあるソフトウェアのなんらかの概念を何個も読み解く必要があり、相当な労力が必要です(知らんけど)
ですが今回はルーツのElmが…CQRSが…みたいな話は一切しません あくまでReduxを初めて使う時の注意事項をまとめておきます
安心して読み進めていただければなと思います

複雑なロジックをReduxに寄せることで、見通しの良いプロジェクトを作りましょう

配列をStoreに入れない

Reduxをちゃんと初めるにあたって、これが一番大事だと言っても過言ではありません
もしあなたが今ReduxのStore設計、パフォーマンスについて悩んでいるならまずこれをやりましょう

Storeに入れるリストを配列からkey-valueにするだけでほぼ全ての悩みが解決します
key-valueというは、TSの型でいうと Record<string, A> みたいな奴の事を指します
ReduxStyleGuide では「正規化しようね」みたいなニュアンスで書かれています
https://redux.js.org/style-guide/style-guide#normalize-complex-nestedrelational-state
https://redux.js.org/recipes/structuring-reducers/normalizing-state-shape

Selectorをたくさん書く

Selectorをたくさん書くとテストがたくさん回ってうれしいです
テストがたくさん回るとここにロジックを寄せたくなりますよね 何でもかんでもSelectorにロジックを寄せるのは正しいことです

Selectorを作る粒度は「オブジェクトを作り出す」ではなく「1センテンスを作る」「1つの計算された数値を渡す」といった粒度で作られるべきです

const stateSelector = state => // ...

// "平均値" のような1つの数値という粒度
export const averageSelector = createSelector([stateSelector], state => {
    // ...
})

// "フルネーム" のような1つのテキストという粒度
export const fullNameSelector = createSelector([stateSelector], state => {
    // ...
})

こうしてできていったSelectorの全てにテストを書きましょう それだけであなたのプロジェクトのカバレッジがどんどん上がっていくはずです

コンポーネントへは1つずつデータを渡す

こちらはあんまりピンと来ないかも知れませんが、正規化していることのメリットを大きく傍受できる大切なノウハウです

どういうことか、サンプルのコードを使って紹介します

type User = {
    id: string
    firstName: string
    lastName: string
    avatarURL: string
}

type State = {
    byIDs: Record<string, User>
}

このようなStateを持つStoreがあったとします
この場合コンポーネントへはどうやってデータを渡すでしょうか?
まずはNGの場合のContainerを見てください

type Props = {
    users: User[]
}
const UserBlockList = props => {
    return (
        <ul>
	    {props.users.map(user => (
	        <li>{`${user.lastName} ${user.firstName}`}</li>
	    ))
	</ul>
    )
}

const UserBlockListContainer = () => {
    const users: User[] = ReactRedux.userSelector(usersSelector)
    
    return <UserBlockList users={users} />
}

StoreからUserの配列を受け取り、UserBlockList というコンポーネントへデータを渡しています
この場合、UserBlockListはmapで回してビューをレンダリングしていますが、注目したいのは ${user.lastName} ${user.firstName} というロジックがPresenterコンポーネント内に書かれていることです
この例は極端にフルネームを出すという例ですが、もっと複雑な計算だったらどうでしょうか?この状態ではテストが回せません

テストを回すにはSelectorにロジックを出す必要があります
これ、結構頻出する問題だと思うのですが、「上のコードからSelectorにロジックを出してテストを回してください」と言われたらどうでしょう
みなさんならどうやりますか…?

さて、ここからが本題です
これをまるっと解決する方法があります

まずはSelectorはこうなるだろうという例です

const stateSelector = // ...

const allUserIDsSelector = createSelector(stateSelector, state => Object.keys(state.byIDs))

const fullNameSelector = createSelector(stateSelector, state => (id: string) => {
    const user = state.byIDs[id]
    if (!user) return ''
    // 
    return `${user.lastName} ${user.firstName}`
})

次にコンポーネント側です

type UserBlockProps = {
    userName: string
}
const UserBlock = React.memo<UserBlockProps>(props => {
    return <li>{props.userName}</li>
})

type UserBlockListProps = {
    userIDs: string[]
    renderUser: (id: string) => React.ReactElement
}
const UserBlockList = React.memo<UserBlockListProps>(props => {
    return (
        <div>
	    {props.userIDs.map(props.renderUser)}
	</div>
    )
})

type UserBlockContainerProps = {
    id: string
}
const UserBlockContainer = (props: UserBlockContainerProps) => {
    const fullNameFactory = ReactRedux.useSelector(fullNameSelector)
    const fullName = React.useMemo(() => {
        return fullNameFactory(id)
    }, [fullNameFactory, props.id])
    
    return <UserBlock userName={fullName} />
}

const UserBlockListContainer = () => {
    const userIDs = ReactRedux.useSelector(allUserIDsSelector)
    
    return (
        <UserBlockList
	    userIDs={userIDs}
	    renderUserBlock={id => <UserBlockContainer id={id} />}
	/>
    )
}

さて、この話の肝はこの3つです

  1. 配列で受け取るようなコンポーネントを作らない
  2. リストをレンダリングしたい場合、必ずIDの配列を受け取る
  3. リストのアイテムをレンダリングする場合、renderPropsを用意する

この3つを守るだけでどれだけ複雑な計算を行う仕様が来てもSelectorへロジックを寄せてテストを回すことができます

@reduxjs/toolkit を使う

もしあなたがこれから新しくプロジェクトを立ち上げる立場なら、 @reduxjs/toolkit (以下RTK)は強力な選択肢です
(代わりに、既存のプロジェクトへの導入はおすすめしません リファクタがめちゃくちゃ大変なので…)

RTKには主に以下の効果があります

  1. ActionTypes, ActionCreatorなどの重複したコードを書かなくて良くなる
  2. Built-in redux-thunk
  3. Built-in fsa
  4. 強力な型のサポート
  5. createEntityAdapter による正規化のサポート

特に③と⑤に関しては使いこなすと相当に強力ですので、是非とも使っていきたいAPIです

同じActionは複数Reducerで購読して良い

re-ducks なんかでActionsやReducerを切ってると「Action作ったらこのReducerしか使わない」ってやるし、それで正解なんですが…
実はReduxはActionをどこのReducerでも購読できます

// HOGEアクション
const HOGE = 'hoge' as const
const hoge = () => ({
    type: HOGE
})
// or
// const hoge = createAction('hoge')
// `createAction` で作った場合は `hoge.type` でActionTypesを参照可能

// こっちのAReducerでも購読できるし
const aSlice = createSlice({
    name: 'a',
    initialState: null,
    reducers: {
        // ActionTypeで購読
        [HOGE]: state => { /* ... */ }
    }
})

// こっちのBReducerでも購読できる
const bReducer = createSlice({
    name: 'b',
    initialState: null,
    reducers: {},
    extraReducers: builder => {
        // これでも同じ こっちのほうがTypeScript的に良い
        builder.addCase(HOGE, state => { /* ... */ })
    }
})

こうしておくと、コードも減るしReduxへの変更を高度に抽象化できるしで良いことづくめです
もちろん、 aReducerbReducer で別々のActionを切っておいて、呼び出す側で同タイミングでその2つを呼ぶというのも良いと思います! お好みで使い分けましょう

createAsyncThunk をうまく使うと、よくあるfetch処理なんかも1つにできますよ😌)

React ⇔ Redux 編

ここまでReact、Reduxのそれぞれのコツをお話しましたが、これらを通しての注意すべき点を挙げておきます

React(UI)とReduxを一緒のものとして考えるのをやめよう

まずこれは大きな声で言いたいです
極端に言えばこれらはどちらも独立して管理されるべきものであって、必ず一緒で対になる必要はありません
僕はドメインから全く違ってても良いと思います(大抵工数の都合上それは叶いませんが…)

ReduxとReactを組み合わせた時の関係性を分解してみます

      → Selectors → 
Store               Containers ↔ Presenters ↔ User
      ← Actions   ← 

こうしてみるとContainersがクッションになっていることがわかります
ActionやSelectorsの呼び出しに対して1つレイヤーを挟むことで、Presenters(UI)とReduxの直接的な依存が無くなっていると考えることができます
ということは、「PresentersとReduxを別々に作って、後でContainersで辻褄合わせる」ことができるわけです

Presentersにはロジックを入れない

Reduxを併用する場合はこれは必ず守りたいやつです
Presenterにロジックを入れるとどんどん処理が複雑化していき、単体テストが回せなくなります
ロジックといっても大抵ReduxのStateを操作しようとするものだと思いますが、そういうのはReduxまで持っていきましょう
そもそもPresentersは見た目のみに責務を持つレイヤーです

Containersにはロジックを入れない

これもPresenterのときと同じ内容です

ContainersはPresenterとReduxの仲を取り持つレイヤーです
こちらは判断がかなり難しく、「これってContainersのものなんだっけ…?」ということが多々あります
そういう時も適切なところへコードを仕舞えるように、コード片付け筋を鍛えてきましょう

Fetchしたデータをそのまま仕舞うのはやめよう

サーバサイドから帰ってくる型とフロントで活用したい型って意外と全然違ったりするんですよね
この場合サーバーサイドからFetchしてきたデータをそのままReduxなどへしまっていくと、ActionやSelectorsなどのロジック、UI側がサーバーサイドから帰ってきた型をそのまま使用することになり、サーバーサイドの改修についていけなくなります

その場合はReduxのドキュメントにもある通り、「シンプルにデータを保管しておく場所」と「フロントで弄る用」でわけてしまうと楽です
https://redux.js.org/recipes/structuring-reducers/normalizing-state-shape#organizing-normalized-data-in-state

ただし、フロントで弄る方の型はサーバーサイドから帰ってくる型とは全く違うものにするのが懸命です
先程も申しましたが、そうすることでサーバーサイドの変更に追従するのが簡単になります

こちらは直接的なバグの元になるので特に気をつけたいところです

(もしサーバーサイドのデータをフロント用に変換する計算コストが勿体ない!と思っているならそれは間違いで、それほどの量のデータを取ってきているとしたら重すぎるのでデータ量を見直したほうが良いです)

おわり

こんなことやんなくても全然サービス一つ作れちゃうんですが、後で自分のコードを見返してSAN値がゴリゴリ削れるのでやっといた方がお得です
今回の話が少しでも、あなたのコードに現れてくれると嬉しいです

それでは楽しいフロントエンド・ライフを!