Reactのこともっとよく知ろう! ~ Redux基礎・イミュータブル編~
Reactに関することで、同じことを別の人に繰り返し説明している...ような気がしたので、
一念発起して勉強会を開くことにしました。
実装半分/解説半分で勉強会を開催予定です。
今記事は、その解説のために作成した資料ですが、この記事単体でも作業可能なように作っています。
今回扱う主な題目は
FLUXとは Reduxのデータフローとは(redux toolkitは何をやっているのか)
なぜイミュータブルである必要があるのか
になります。
lesson1 - FLUXとは Reduxのデータフローとは(redux toolkitを実装しながら学ぶ)
リポジトリを準備する
勉強用リポジトリをGithubにて準備しています。
Github takanokana/react-tag
上記リポジトリのブランチ lessson/1に切り替えて作業を行なってください。
$ npm ci
$ npm run start
コマンドで、ローカルサーバーが問題なく動くか確認してください。
このリポジトリでは、ボタンを押すと色が変わるテキストボックスと、
各色毎のページが用意されています。
これから実装するのは
・postされたテキストの情報
・色の情報
をreduxで保持し、それを各色のページで表示する、というものです。
FLUXとは
FLUX公式
FLUXはMVC/MVVMといったアーキテクチャに変わる新しいアプリケーションアーキテクチャです。
MVCモデルでwebアプリケーションを組むと、矢印があっちこっち飛び交って、ModelとViewが複雑になり、あっという間に予測不可能なアプリケーションになってしまいます。
そこでFacebookは新しいFLUXというアーキテクチャを考案しました。
データフローを一方向にすることで予測しやすいコードを目指しています。これでコードを追加するのにブルブル震える心配もなくなるわけです。
- Action・・・Viewなどから発火するイベント。(ボタンを押す,ページを読み込む..etc)
- Dispatcher・・・アクションを元にStoreへ通信する橋渡し役
- Store・・・アプリケーション全体のデータ、ビジネスロジックを持つ
- View・・・見た目部分
Redux
上記のアーキテクチャを元に作られた状態管理フレームワークがRedux
です。
FLUXの流れを汲んでいますが、Redux イコール FLUXではなく、いくつか相違があります。
画像参照: Reduxを分かりやすく解説してみた
Reduxの哲学として3つが掲げられています。
参照: Three Principles
・Single source of truth
Storeはアプリケーションでたった一つというシングルトン設計。
・State is read-only
stateは読み取り専用であること
・Changes are made with pure functions
変更は必ず純粋関数で。
感覚としては、FLUXの元思想より厳格で、React向けの仕様になっています。
Redux-toolkit
Redux-toolkitはreduxのエコシステム周りを一纏めにし、構築に時間をかけずサクサクかける優れものです。コード量が激減した反面、内部で何が起こっているのが少し分かりづらい面があります。
実際に実装(やっと)しながら解説していきます。
storeの実装
今回storeに溜め込まないといけない情報は、色の文字列と、付箋に書かれた文字列です。
まずはstore構築に必要なファイル/フォルダを作成します。
$ mkdir src/tsx/stores
$ mkdir src/tsx/stores/slices
$ touch src/tsx/stores/index.ts
そして、storeを構築します。
import { configureStore } from '@reduxjs/toolkit'
import { TypedUseSelectorHook, useSelector as rawUseSelector } from 'react-redux'
const store = configureStore({
reducer: {
}
})
export default store
export type RootState = ReturnType<typeof store.getState>
export const useSelector: TypedUseSelectorHook<RootState> = rawUseSelector
export type AppDispatch = typeof store.dispatch
現在reducerの中身が空っぽで、stateはまだありません。
後ろ三行はreduxをで型安全に操作するためのおまじないです。
そして、react-redux
のProviderコンポーネントを用いてstoreをreactに繋げます。
+ import { Provider } from 'react-redux'
+ import store from './stores/index'
...
ReactDOM.render(
+ <Provider store={store}>
- <>
<GlobalStyle />
<Router>
<Header />
<Switch>
<Route path="/" exact component={Top} />
<Route path="/list/white" exact component={TagList} />
<Route path="/list/blue" exact component={TagList} />
<Route path="/list/green" exact component={TagList} />
<Route path="/list/red" exact component={TagList} />
<Route path="/list/yellow" exact component={TagList} />
</Switch>
</Router>
- </>,
+ </Provider>,
app
)
では、sliceを書いていきましょう。
sliceというのはredux-toolkitに出てくる概念で、シングルトンであるstoreを分割して分かりやすくしたものです。これは見かけ上分割して管理しやすくしているだけで、実際のstoreは一つだけということに注意しましょう。
$ touch src/tsx/stores/slices/tagListSlice.ts
import { createSlice } from '@reduxjs/toolkit'
import { RootState } from '../index'
const tagListSlice = createSlice({
// slice名
name: 'tagList',
// 初期値
initialState: {
nextId: 1,
data: [{
color: "white",
text: "夜ネギを買う",
id: 0,
}]
},
//各reducer 第一引数でstate情報を受け取り、第二引数でactionを受け取る
reducers: {
addTag: (state, action) => {
}
},
})
// actionをexport
export const { addTag } = tagListSlice.actions
// state情報をexport
export const selectTags = (state: RootState) => state.tagList
// reducerをexport → storeへ
export default tagListSlice.reducer
上記はredux-wayというディレクトリ構成方法であれば、action/reducerといった要素毎にフォルダを作って構築しますが、redux-toolkitではre-ducksというディレクトリ構成になります。
これは一つのファイルに関連するaction/reducerなどを記述してしまう方法で、それぞれ密結合なので
ファイルをあっちこっち確認する必要がなく、管理が楽です。
参照:Reduxでのディレクトリ構成3パターンに見る「分割」と「分散」
そして上記sliceをstoreへ繋ぎ込みます。
import { configureStore } from '@reduxjs/toolkit'
import { TypedUseSelectorHook, useSelector as rawUseSelector } from 'react-redux'
+ import tagListSlice from './slices/tagListSlice'
// それぞれのSliceを呼び出して結合する
const store = configureStore({
reducer: {
// 識別する名前: importしてきたReducer名
+ tagList: tagListSlice
}
})
export default store
export type RootState = ReturnType<typeof store.getState>
export const useSelector: TypedUseSelectorHook<RootState> = rawUseSelector
export type AppDispatch = typeof store.dispatch
storeの情報をviewに描画する
現在store内のstate.tagListには
{
nextId: 1,
data: [{
color: "white",
text: "夜ネギを買う",
id: 0
}]
},
という初期stateが入っているので、これを元にviewを更新してみます。
まずは、各ページが特定のカラーのpropsを持つように更新します。
ReactDOM.render(
<Provider store={store}>
<>
<GlobalStyle />
<Router>
<Header />
<Switch>
<Route path="/" exact component={Top} />
+ <Route path="/list/white" exact render={() => <TagList clrType="white" />} />
+ <Route path="/list/blue" exact render={() => <TagList clrType="blue" />} />
+ <Route path="/list/green" exact render={() => <TagList clrType="green" />} />
+ <Route path="/list/red" exact render={() => <TagList clrType="red" />} />
+ <Route path="/list/yellow" exact render={() => <TagList clrType="yellow" />} />
</Switch>
</Router>
</>,
</Provider>,
app
)
renderに変えることでpropsを渡すことができます。
そしてselectorにカラーのpropsを渡し、特定の色のデータだけを取得できるような関数を準備します。
interface tag {
color: string,
text: string,
id: number
}
...
// 特定の色のタグのみ
export const colorSelect = (color: string, state) => {
const tags: tag[] = state.tagList.data
return tags.filter((tag) => {
return tag.color === color
})
}
そして、実際にviewに描画します。
import React, { FC } from 'react'
import { css } from '@emotion/core'
// store
import { useSelector } from '../../stores/index'
import { colorSelect } from '../../stores/slices/tagListSlice'
// style
import { colors } from '../../style/components/atoms/Button'
interface ItagList {
clrType: string
}
export const TagList: FC<ItagList> = ({ clrType }) => {
const data = useSelector((state) => colorSelect(clrType, state))
return (
<section>
{data.map(tagData => (
<div css={TagCss(clrType)} key={tagData.id}>{tagData.text}</div>
))}
</section>
)
}
const TagCss = (clrType: string) => css`
border: 1px solid #ccc;
padding: 20px;
background: ${colors[clrType].background};
white-space: pre-wrap;
`
実際にheaderから白の一覧に遷移すると、下記のようにstateを元にviewが描画されます。
おまけ:なぜview側ではなくstoreでcolorSelectのようなフィルターをかけるのでしょうか?
いくつか理由がありますが、一つは各ビューで使い回せるからです。
また、もう一つの理由は不要なレンダリングを避けるためです。
useSelectorは内部キャッシュを持っており、返す値が前回と等しければキャッシュぶんを返します。
つまり、useSelectorから返る値はviewで使う分だけのデータに極力押さえておいたほうが再レンダリング時に再計算が起きにくいということになります。
useSelector
に関してはパフォーマンス面で考慮しないといけないこともあるので、次回の記事で詳しく取り上げようと思います。
参考:Reduxを用いる時にオススメしたい3つのTips
これでstoreの情報をviewに反映できました!
情報をstoreに送る
次はstoreのstateを変更する実装です。
reduxにおいて、storeの情報を更新するにはactionをdispatchしなければなりませんでしたね。
actionはこのような形になっています。
{
type: "GET_COUNT",
payload: 2
}
実はactionはtypeが必須でユニークであること以外は特に決まりごとはないのですが、
それだと指針がなさすぎ!ということで、多くの人は下記の非公式のコード規約に乗っ取ってactionを記述しています。
redux-toolkitで自動生成されるactionも上記のコード規約に則っています。
また、ActionCreatorというのは、上記のオブジェクトを返してくれる関数のことです。
const getCount = num => ({
type: "GET_COUNT",
payload: num
})
ちなみにですがactionとactionCreatorは混合されがちです。
redux-toolkitでは、reducersに記述した時点で裏で自動でactionのtype文字列とaction,actionCreatorが自動生成されています。強い。
const tagListSlice = createSlice({
// slice名
name: 'tagList',
// 初期値
initialState: {
nextId: 1,
data: [{
id: 0,
color: "white",
text: "夜ネギを買う"
}]
},
//各reducer 第一引数でstate情報を受け取り、第二引数でユーザーが操作した情報を受け取る
+ reducers: {
+ addTag: (state, action) => {
+ state.data = [
+ ...state.data,
+ {
+ ...action.payload,
+ id: state.nextId
+ }
+ ]
+ state.nextId = state.nextId + 1
+ }
+ },
})
そしてpostBoxを下記のように変更し、情報をpostできるようにしましょう。
import { useDispatch } from 'react-redux'
// store
import { addTag } from '../../../stores/slices/tagListSlice'
export const PostBox: FC = () => {
const [color, setColor] = useState('white')
const [postTxt, setPostTxt] = useState('')
const dispatch = useDispatch()
const setClrHandler = (color: string) => {
setColor(color)
}
const setTxtHandler = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setPostTxt(e.target.value)
}
const postHandler = () => {
dispatch(addTag({
color: color,
text: postTxt
}))
setPostTxt('')
}
return (
<div css={PostBoxCss}>
<textarea
css={TextBoxMain(color)}
onChange={setTxtHandler}
value={postTxt}
/>
<div css={BottomBlockCss}>
<div>
<ColorBtn
type="white"
setClrHandler={setClrHandler}
/>
...
<button
css={PostBtnCss}
onClick={postHandler}
色ごとのページに付箋を描画できているかと思います。
storeがシングルトンであることについて
sliceで意識しづらいですが、actionは全てのreducerを通過しています。
あたかも特定のreducerだけが動いているように見えるのは、actionのtypeの文字列が該当していれば変化を加え、そうでなければ以前のstateのまま、という処理がなされているためです。
実はsliceの自動生成のために、一つreduxの利点が殺されている面があります。
私の言いたいことは全てこちらのスライドに書かれていたので、ぜひご一読ください。
Redux の利点を振り返る
もっと実装しよう
・付箋に記入時刻を表示する実装
・付箋を削除する実装
・付箋を入れ替える実装
...etc
lesson2 - なぜイミュータブルである必要があるのか
【参考資料】 なぜ昨今のJavaScriptではイミュータブルであるべきと言えるのか歴史的背景を踏まえて言語化する
イミュータブル(immutable)とは?
不可変immutable <=> mutable可変
元のオブジェクトに変更を加えない実装のこと
const a = [1,2,3]
a.push(4) // a [1, 2, 3, 4] mutable
const b = [ ...a, 4 ] // a [1, 2, 3] immutable
イミュータブルであればaはいつでもどこでも必ず[1,2,3]であることが保証されます。
なので予測がしやすくテストが容易です。
そして何よりreactにおいてはパフォーマンスに影響大です!
これについては詳しく後述します。
オブジェクトにおける浅い/深いとは
浅い比較 ・・・オブジェクトのインスタンス(参照元)が等しいかどうか?のみを比較する。
const data = {
name: 'irico',
pass: 'admin'
}
const data2 = data
data2.name = 'takano'
console.log(data === data2) // true
このように、data2はdataのインスタンスを参照していますので、厳格な比較(===
)においてtrueとなります。
Reactでは浅い比較がめっちゃ出てきます。
なぜかというと、どんなにネストした情報でも深い比較と比べてコストがかからないからです。
なので、オブジェクトをimmutableに保ち、無駄なレンダリングを抑えれるようにすることが大切になります。
以上のことを念頭においてreact公式のパフォーマンス最適化の記事を読むと、理解が進みやすいかと思います。
例えば、以前作った付箋を貯めるReactですが、TOP階層で色データを持っているため、
不要なレンダリングが発生しています。
(Chromeの拡張で再レンダリングが起きるか確認してみましょう。)
Highlight Updateという機能を使うと、レンダリングされた部分に枠線が表示されるようになります。
色を変えるボタン群は、色の情報を使っていないので再レンダリングが不要ですが
親が再レンダリングしたことによって子コンポーネントも再レンダリングしてしまいます。
この程度の描画コストであれば正直大きな差は出ませんが、内部で複雑な処理をしている場合、
今から行うレンダリングを抑える調整が必須になります。
React.memoを使用して再レンダリングを防ぎましょう。これは、propsを浅く比較し、falseであれば再レンダリングさせます。
// components
import { ColorSelectBtns } from './ColorSelectBtns'
...
return (
<section css={PostBoxCss}>
<textarea
css={TextBoxMain(color)}
onChange={setTxtHandler}
value={postTxt}
/>
<div>
<ColorSelectBtns
setClrHandler={setClrHandler}
/>
<button
css={PostBtnCss}
onClick={postHandler}
>Postする</button>
</div>
</section>
)
}
上記のようにColorSelectBtnsに分割します。
import React, { FC, memo } from 'react'
import { ColorBtn } from '../../components/atoms/ColorBtn'
interface IcolorSelectBtns {
setClrHandler: (color: string) => void
}
export const ColorSelectBtns: FC<IcolorSelectBtns> = memo(({
setClrHandler
}) => (
<>
<ColorBtn
clrType="white"
setClrHandler={setClrHandler}
/>
<ColorBtn
clrType="blue"
setClrHandler={setClrHandler}
/>
<ColorBtn
clrType="green"
setClrHandler={setClrHandler}
/>
<ColorBtn
clrType="red"
setClrHandler={setClrHandler}
/>
<ColorBtn
clrType="yellow"
setClrHandler={setClrHandler}
/>
</>
))
ColorSelectBtnsは React.memo
という高階関数でラッピングします。
これにより、setClrHandlerの浅い比較がfalseでない場合は再レンダリングがかからなくなります。
しかし、devtoolで見ても再レンダリングがかかっています...なぜでしょう?
再レンダリングがかかっているということは、setClrHandlerが描写前後で参照元が異なるということです。
実際の実装箇所を見ていただくとわかりますが、こちらの記述だと描写毎に const setClrHandler
が走って毎度インスタンス化してしまいます。
const setClrHandler = (color: string) => {
setColor(color)
}
これを防ぐ手はいくつかありますが、今回はuseCallback
を使います。
インラインのコールバックとそれが依存している値の配列を渡してください。
useCallback
はそのコールバックをメモ化したものを返し、その関数は依存配列の要素のいずれかが変化した場合にのみ変化します。これは、不必要なレンダーを避けるために(例えばshouldComponentUpdate
などを使って)参照の同一性を見るよう最適化されたコンポーネントにコールバックを渡す場合に便利です。
const setClrHandler = useCallback((color: string) => {
setColor(color)
}, [])
これで関数がメモ化され、再描画が起きてもsetClrHandlerの参照元は同一となります。
実際にdevtoolで確認すると、各ボタン周りの再レンダリングの線が消えているのが確認できるかと思います。
ということでimmutableな更新を心がけましょう!
ネストが深くてどうにもならない時
immutableなオブジェクトを生成する際に大活躍のスプレッド構文ですが、一点落とし穴があります。
const data = [{ color: "white", text: "夜ネギを買う", id: 0,}]
const newData = [...data]
newData[0]の参照先は当然data[0]の参照先と違うはず!新しいインスタンスだもんね~と思いませんか?
newData[0] === data[0] // true
だがしかし、インスタンス参照先が同じのようです。
これはシャローコピーと言われる挙動です。反義語はディープコピーになります。
つまり、スプレッド構文(Object.assignも同様です)では、ネストの深い配列やオブジェクトまで深くコピーできない!! ということです。
JSでdeepコピーするテクニックとしてJSONを使うわざがありますが、undifenedや関数が消滅してしまうなどの問題もあります。(JSONなので)
person2 = JSON.parse(JSON.stringify(person))
そこで登場するのが、JSで楽チンにイミュータブルな実装ができる
・immer.js
・immutable.js
といったライブラリです!
しかも
しかもなんと!!!
redux-toolkitは最初からimmer.jsが内部で実装されています。
reduxに関しては私たちがすることは何もありません😮
reducers: {
addTag: (state, action) => {
state.data = [
...state.data,
{
...action.payload,
id: state.nextId
}
]
state.nextId = state.nextId + 1
}
},
})
上記はstateを直接変更している(mutable風)ですが、実際はimmutable!!!!なのです。
immerの落とし穴
それじゃあ何にも気にしなくて良いのか?
というと実はそうではありません。
immerには落とし穴があるので、それを考慮して実装する必要性があります。
immerの落とし穴 公式Doc
・循環関数とかダメ
・undefined無理
・window.locationとか無理
・stateをまんま入れ替えるのダメ
結構色々あるので、つまづかないようしっかり把握しておきましょう。
(immerが内部実装されてるのは初心者向きじゃなくない!?というredux-toolkitに対する批判もあるくらいです。)
次回勉強会の予定
・ミドルウェアについて(redux-thunk, saga)
・パフォーマンス改善について
Discussion