背景
Reactを一通り使えるようになって、そろそろReduxを、と思いました。
でも、あれだけ充実している公式のドキュメントを見ても、Qiitaにうなるほどある親切な解説を読んでも、いまひとつピンと来ませんでした・・・
ですが、公式に紹介されているチュートリアル動画が非常にわかりやすかったので、ひとつなぞってみよう、と投稿しました。(制作者であるDan Abramov様には了解を得ています)
Getting Started with Redux
この動画の、簡単なカウンターアプリ作成までをなぞっていきます。基本的には、動画を見ていなくても大丈夫なように書いていきたいと思います。
以下のコードは基本的に上記の動画をもとに一部改変しています。また、コードについてはes2015スタイル、セミコロンなしのスタイルでの記述をしています。
環境
当方の環境です。
- Windows10
- node v5.10.0
対象
- Reactはひととおり理解した
- でも、Reduxはどうもドキュメントをあさってもピンとこない。Redux初心者
- ついでに、es2015もある程度理解している
環境構築
まずは環境構築です。
適当なプロジェクトフォルダを作成して
npm init -y
npm i -D babel babel-cli babel-loader babel-preset-es2015 babel-preset-react expect react react-dom react-redux redux webpack
(※ 2018/03/28 追記
-
babel
をインストールすると、「In 6.x, the babel package has been deprecated in favor of babel-cli.」と警告が表示されるようになりました。babel
はbabel-cli
に取って代わられたようなので、インストール対象から外して構いません。 -
babel-preset-es2015
をインストールすると、「we recommend using babel-preset-env now: please read babeljs.io/env to update!」と警告が表示されるようになりました。気になる方は、babel-preset-es2015
の代わりにbabel-preset-env
をインストールしてください。 - webpack4から
webpack-cli
が必要になりました。加えてインストールしてください。 - 以上を踏まえると、以下のようになります。
npm init -y
npm i -D babel-cli babel-loader babel-preset-env babel-preset-react expect react react-dom react-redux redux webpack webpack-cli
)
を実行します。この中で、expectはテストによる動作説明で使用します。
また、動画中ではcode penのようなサイトで直接コードを編集・実行していましたが、webpackとbabelにより通常通りのファイル編集をしていきたいと思います。
プロジェクトルートフォルダにbabel用の設定ファイルを置きます。
{
"presets": ["es2015", "react"]
}
(※ 2018/03/28 追記
babel-preset-es2015
の代わりにbabel-preset-env
をインストールした方は、
{
"presets": ["env", "react"]
}
となります。
)
また、webpack用の設定ファイルもプロジェクトルートフォルダに作成します。
module.exports={
entry: ["./src/app.js"],
output: {
path: __dirname+"/dist",
filename: "app.js"
},
module: {
loaders: [
{
test: /\.jsx?$/,
exclude: /node_modules/,
loader: 'babel'
}
]
}
}
(※ 2018/03/28 追記
webpack4から記載方法が変更になりました。
module.exports={
mode: "development",
entry: ["./src/app.js"],
output: {
path: __dirname+"/dist",
filename: "app.js"
},
module: {
rules: [
{
resource: {
test: /\.jsx?$/,
exclude: /node_modules/
},
use: ["babel-loader"]
}
]
}
}
)
この設定のとおり、srcフォルダにソース、distフォルダに成果物を入れるので
mkdir src
mkdir dist
でフォルダを作成してください。
簡単なカウンターアプリ作成
まずはReduxのみで
ReactがViewのみを担当するライブラリとしたら、ReduxはStateのみを管理するライブラリであると言えます。よくReactとペアで語られることが多いReduxですが、Reactだけではなく、jQueryやほかの仕組みと組み合わせても使用できます。
そして、最初はReactを抜きに動作をみていったほうが、いったい何をするライブラリなのかがよくわかると思います。チュートリアル動画でも、最初はReduxのみを使用していきます。
用語の定義
動画の1~3では、Reduxのもつ3つの基本原則について説明しています。ここで、最初に用語の定義をしておきます。
- 状態(State)を保管する場所であるStoreは1つだけ
- 状態(State)は基本読み取りのみ。書き込むにはStoreが提供するメソッドであるDispatchでActionを投げる時だけ
- 実際にActionを受け取ってStateを変更するのはReducerが受け持つが、Reducerは副作用を持たない関数でなければならない
State
状態(State)は、Reactでいうところのthis.stateのような状況によって変遷する各種パラメータです。Reactはコンポーネントごとに自前で状態を管理していましたが、Reduxではアプリ全体で状態をまとめてStoreに管理します。
Action
Actionは仰々しく命名されていますが、実態はただのオブジェクトです。ただ、何の動作かを説明するtypeというプロパティは必須になっています。
{
type: 'INCREMENT'
}
{
type: 'ADD_TODO',
text: '追加するtodoのテキスト'
}
Reducer
Reducerは、現在のStateとActionを受け取って、Actionに応じて変更した結果のStateを返す関数です。
ただし、原則3にもありましたが、副作用をもたない純関数である必要があるために直接Stateをいじらず、必ず状態をいじった結果の新しいStateを返します。
まずはReducerから
まずはReducerの動作から確認していきます。
srcディレクトリにapp.jsを作成、そこにReducerの機能を書いていきます。今後、単純化のためにapp.jsにすべての機能を書いていきます。
ReducerはStateとActionを受け取って、Stateを返す関数なので、とりあえずそのまま書きます。
(ここでStateはカウンタの数字そのものを指します)
const counter = (state, action) => {
return state
}
コンソールで、npx webpack -w
を実行すると、ビルドされます。
Hash: 8dc0163cef55d3363d40
Version: webpack 1.13.0
Time: 60ms
Asset Size Chunks Chunk Names
app.js 1.59 kB 0 [emitted] main
+ 2 hidden modules
これに、Reducerとして期待される動作を、テストを書くことで明らかにしていきます。
まず、'INCREMENT'というActionを受け取ったら、state(カウンタの数字)を0から1に変化させたいとします。そこで
expect(
counter(0, {type: 'INCREMENT'})
).toEqual(1)
というテストを書きます。さらに、もう1回INCREMENTアクションを送ったらカウンタは2になってほしいので
expect(
counter(1, {type: 'INCREMENT'})
).toEqual(2)
さらにカウンタ減少のアクションも考慮しましょう。
DECREMENTアクションを送ったらカウンタは1減少してほしいので、
expect(
counter(2, {type: 'DECREMENT'})
).toEqual(1)
とします。
ここで、テストにexpectライブラリを使用しているのでimportで読み込みます。更にテストがすべてパスしたら、コンソールにパスした旨を表示させたいので、全体のコードは以下のようになりました。
import expect from 'expect'
const counter = (state, action) => {
return state
}
expect(
counter(0, {type: 'INCREMENT'})
).toEqual(1)
expect(
counter(1, {type: 'INCREMENT'})
).toEqual(2)
expect(
counter(2, {type: 'DECREMENT'})
).toEqual(1)
console.log('test passed!')
コンソールでnode dist/app.js
を実行すると、何の実装もしていないので当然エラーになります。
早速実装していきましょう。
受け取ったActionに応じてstateを変化させればいいので、
const counter = (state, action) => {
switch(action.type) {
case 'INCREMENT':
return state + 1
case 'DECREMENT':
return state - 1
}
}
でよさそうです。
node dist/app.js
を実行するとtest passed!
と表示されました。
ただし、テストはまだ続きます。
未定義のアクションが来たらエラーではなく、現状のstateがただ返ってほしいので、
//未知のコマンドにはundefinedではなく、現状のStateがそのまま返る
expect(
counter(1, {type: 'UNKNOWN'})
).toEqual(1)
をテストの最後に加えます。実行すると、Error: Expected undefined to equal 1
となりますので、これも実装しましょう。
const counter = (state, action) => {
switch(action.type) {
case 'INCREMENT':
return state + 1
case 'DECREMENT':
return state - 1
default:
return state
}
}
普通のjsの対応ですね。未定義のActionがきたら、stateをただ返します。テストがパスするのを確認します。
さらに、stateにundefined、かつActionには何も渡さない、つまり初期設定の場合は0にリセットしてほしいので、
//初期設定時には0が返る
expect(
counter(undefined, {})
).toEqual(0)
というテストを一連のテストの最後に追加します。当然テストを実行すると失敗するのでこれも実装します。・・・が、これは、単にstateの初期値に0を設定するだけでOKです。
const counter = (state = 0, action) => {
switch(action.type) {
case 'INCREMENT':
return state + 1
case 'DECREMENT':
return state - 1
default:
return state
}
}
...これでreducerの完成です。全くReduxの文字は出てきませんでしたが、Reducerはそもそも単純な関数に過ぎないので、このような形で作れます。
Storeの作成
次にReducerと密接に関係しているStoreを作成します。
Storeの定義は、Stateを保管する場所というものでした。
ここで、いよいよReduxをインポートします。ここではStoreを作成する関数であるcreateStoreのみインポートします。
app.jsの先頭部分にimportを追加します。また、先ほどのテスト部分は使用しないので、まるごと削除して、コードは以下のようになります。
import expect from 'expect'
import { createStore } from 'redux'
const counter = (state = 0, action) => {
switch(action.type) {
case 'INCREMENT':
return state + 1
case 'DECREMENT':
return state - 1
default:
return state
}
}
まずはStoreを作成しましょう。Store作成には、先ほどインポートしたcreateStoreを使用します。この関数はreducerを引数にとります。
//reducer
const counter = (state = 0, action) => {
switch(action.type) {
case 'INCREMENT':
return state + 1
case 'DECREMENT':
return state - 1
default:
return state
}
}
//作成したreducerであるcounter関数を引数に指定してstoreを作成
const store = createStore(counter)
storeは重要な関数を3つもっています。
getState
まずはgetStateです。これは、文字通り現在のstateを返します。
...
//作成したreducerであるcounter関数を引数に指定してstoreを作成
const store = createStore(counter)
//getStateで、現在のstateを返す
console.log(store.getState())
上記のコードを追加して実行すると、コンソールに0
と表示されます。これは、先ほど実装したようにstateが0で初期化されたことを表しています。
dispatch
次に、dispatchです。これはActionを引数にとり、Reducerに現在のStateとActionを渡します。結果、ReducerがActionに応じてStateを変化させます。確認するため、さらにコードを追加します。
//getStateで、現在のstateを返す
console.log(store.getState())
//dispatch(<アクション>)で、reducerにStateとActionを送る・・・Stateがアクションの内容に
//より変化
store.dispatch({type: 'INCREMENT'})
//Stateが変化したか確認
console.log(store.getState())
コンソールで実行すると0
と1
が表示されます。dispatchによってStateが変化しています。
subscribe
最後がsubscribeです。これは関数を引数にとり、dispatchが実行される都度、引数に渡された関数を実行します。つまり、ここにStateを取得して画面を更新する関数を引数に設定すれば、dispatchが実行される都度、画面が更新されることになります。
例として、画面のどこでもクリックすれば延々とカウントアップさせるようにしてみます。
...
//作成したreducerであるcounter関数を引数に指定してstoreを作成
const store = createStore(counter)
//subscribe関数に、現在のstateの状況を画面に表示する関数をセット
store.subscribe(() => {
document.body.innerText = store.getState()
})
//documentオブジェクトをクリックしたらINCREMENTアクションをdispatchする
//イベントを追加
document.addEventListener('click', () => {
store.dispatch({ type: 'INCREMENT' })
})
ブラウザ上で確認したいので、distフォルダにindex.htmlファイルを作成します。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Counter</title>
</head>
<body>
<script src="app.js"></script>
</body>
</html>
ブラウザで実行すると、真っ白な画面が表示されますが、どこでもいいのでクリックすると、カウントアップしていきます。
真っ白もどうかと思うので、少しコードをいじります。
//作成したreducerであるcounter関数を引数に指定してstoreを作成
const store = createStore(counter)
//画面更新用の関数を作成
const render = () => {
document.body.innerText = store.getState()
}
//subscribe関数に、現在のstateの状況を画面に表示する関数をセット
store.subscribe(render)
//最初に画面を表示(0が表示される)
render()
//documentオブジェクト(画面上すべて)にクリックしたらINCREMENTアクションをdispatchする
//イベントを追加
document.addEventListener('click', () => {
store.dispatch({ type: 'INCREMENT' })
})
これできちんと画面には初期値から表示されるようになりました。
・・・ここで気づかれたと思いますが、viewに関するコードが入り込んできました。
ここを、次にReactに変えていきます。
viewにReactを使用する
view部分をReactに置き換える
Reactを使用しますので、それぞれreact、react-domをインポートします。
...
import React from 'react'
import ReactDOM from 'react-dom'
...
また、dist/index.htmlに、Reactの起点となるポイント(id="root"のdiv要素)を追加します。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Counter</title>
</head>
<body>
<h1>counter</h1>
<div id="root"></div>
<script src="app.js"></script>
</body>
</html>
まず、JSXでCounterコンポーネントを作成します。propsとしてカウンターの値を
受け取ります。
...
//Counterコンポーネントを作成。カウンターの値(value)をpropsにとる
const Counter = ({value}) => (
<h1>{value}</h1>
)
//作成したreducerであるcounter関数を引数に指定してstoreを作成
const store = createStore(counter)
...
さらに、それをReactDOMでrenderします。
...
const Counter = ({value}) => (
<h1>{value}</h1>
)
//作成したreducerであるcounter関数を引数に指定してstoreを作成
const store = createStore(counter)
//画面更新用の関数を作成
const render = () => {
ReactDOM.render(
<Counter value={store.getState()} />,
document.getElementById('root')
)
}
...
ボタンの追加
どうせならカウントアップ、カウントダウンのボタンを追加しましょう。
const Counter = ({
value,
onIncrement,
onDecrement
}) => (
<div>
<h1>{value}</h1>
<button onClick={onIncrement}>+</button>
<button onClick={onDecrement}>-</button>
</div>
)
propsとして、valueだけでなく、ボタンをクリックした際のイベントも受け取るようにします。
render関数の中のReactDOMの中も変更しましょう。
...
const render = () => {
ReactDOM.render(
<Counter
value={store.getState()}
onIncrement={() =>
store.dispatch({
type: 'INCREMENT'
})}
onDecrement={() =>
store.dispatch({
type: 'DECREMENT'
})}
/>,
document.getElementById('root')
)
}
...
イベントと結びつける部分は、直接dispatchする関数を返すようにしています。
さらに、イベントリスナ登録部分は削除しておきます。(Reactが担当するため)
//documentオブジェクト(画面上すべて)にクリックしたらINCREMENTアクションをdispatchする
//イベントを追加
// document.addEventListener('click', () => {
// store.dispatch({ type: 'INCREMENT' })
// })
最終的なコードは以下のようになりました。
import expect from 'expect'
import { createStore } from 'redux'
import React from 'react'
import ReactDOM from 'react-dom'
const counter = (state = 0, action) => {
switch(action.type) {
case 'INCREMENT':
return state + 1
case 'DECREMENT':
return state - 1
default:
return state
}
}
const Counter = ({
value,
onIncrement,
onDecrement
}) => (
<div>
<h1>{value}</h1>
<button onClick={onIncrement}>+</button>
<button onClick={onDecrement}>-</button>
</div>
)
//作成したreducerであるcounter関数を引数に指定してstoreを作成
const store = createStore(counter)
//画面更新用の関数を作成
const render = () => {
ReactDOM.render(
<Counter
value={store.getState()}
onIncrement={() =>
store.dispatch({
type: 'INCREMENT'
})}
onDecrement={() =>
store.dispatch({
type: 'DECREMENT'
})}
/>,
document.getElementById('root')
)
}
//subscribe関数に、現在のstateの状況を画面に表示する関数をセット
store.subscribe(render)
//最初に画面を表示(0が表示される)
render()
まとめ
まだまだ簡単なものしか作っていないので、説明していない各種概念、関数等ありますが、こうして一つ一つ機能を取り上げてみると、それぞれの果たしている役割がどのようなものか見えてこないでしょうか?
動画では、これからさらにTODOリストの作成に入っていきますが、このドキュメントではここまでとします。
このような素晴らしい動画をフリーレッスンで公開しているDan Abramov様に感謝です。