「Reactをそれなりに触れるようになったけどReduxになった瞬間ドはまりする」
「Reduxの学習教材やYoutube動画を見てもイマイチ理解できなかった」
「useReducerの使うべきタイミングが分からない」
本日はそんな方に向けてReduxの内容をザックリ理解してもらうための記事になります。
公式ドキュメントにすべて載っている内容ですが、私たちが触れる情報はどうしても難しい概念に関する説明が多いです。
自分の経験として概念ではなく、useReducerの動きを調べていたうちにReduxを理解できたのでuseReducerの使い方に焦点を絞って書いていきます。
こちら読み終わる頃にはuseReducerの使い方はもちろん、Reduxのことまでザックリ理解できているはずです。
また動画もあるので必要に応じて使ってください。
useStateとuseReducerの違い
いきなり結論を言うと、useReducerでやっていることはuseStateと全く同じです。
「え?じゃあuseReducerって何のために存在してるの?」となったかもしれないので、同じプログラムをuseStateとuseReducerで書き比べてみたいと思います。
まずuseStateを使って以下のようなコンポーネントを作ります。
import React, { useState } from "react";
const FetchApi = () => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const [todo, setTodo] = useState({});
const fetchTodos = () => {
setLoading(true);
setError(false);
fetch("https://jsonplaceholder.typicode.com/todos/1")
.then((res) => res.json())
.then((data) => {
setLoading(false);
setTodo(data);
})
.catch((err) => {
setLoading(false);
setError(true);
});
};
return (
<>
<div>
<button onClick={fetchTodos}>{loading ? "処理中" : "クリック"}</button>
<p>{todo?.title}</p>
<span>{error && "エラーです"}</span>
</div>
</>
);
};
export default FetchApi;
上記はコンポーネントとして使用するファイルを想定しているので、App.jsに読み込んでおきます。
import logo from "./logo.svg";
import "./App.css";
import FetchApi from "./screens/FetchApi";
function App() {
return (
<>
<FetchApi />
</>
);
}
export default App;
画面上はボタンだけが表示されていますね。
こちらのコードではボタンをクリックするとAPIを呼んでテキストを表示します。
APIは無料で使えるjsonplaceholderを使っています。
https://jsonplaceholder.typicode.com/
動作を確認してみましょう、ボタンをクリックします。
ボタンの下にテキストが表示されていますね。
こちらのテキストはjsonplaceholderで用意されているテキストデータなので私たちは用意しなくても大丈夫です。
今回useStateで管理しているステートは3種類です。
①loading:API取得時の待機時間をtrue、falseで管理
②todo:APIから取得したデータをオブジェクトで管理
③error:エラーの有無をtrue、falseで管理
①loadingが若干イメージしずらいかもしれませんが、動作させるとボタンの表示が一瞬だけ「処理中→クリック」に変わっているはずです。
とても簡単なプログラムですが、現時点でuseStateだけで仕様を作り上げることが出来ています。
冒頭の話に戻るのですが、useReducerは「useStateで管理するステートが多くなったとき」に使うことが良いとされています。
現状ですとコンポーネントは1ファイルだけで、そのなかで3個のステートが管理されています。
これがもしコンポーネントが10ファイルに分散していて、各コンポーネントに3個ずつステートがあったとしたら結構大変そうじゃないですかね。
さらにコンポーネントが分散しているだけで、各コンポーネントで「同じようなステート」を別々でuseStateで定義することも考えられます。
後々になって管理が大変ですし、思わぬバグの温床になってしまうんですね。
さてそこでuseReducerの登場ですが、useReducerはまずコンポーネントとは別で「ステートを管理する専用のファイル」を用意します。
ここではreducer.jsとでもします。
export const init = {
loading: false,
todo: {},
error: false,
};
export const todoIf = (state, action) => {
if (action.type === "START") {
return {
loading: true,
todo: {},
error: false,
};
} else if (action.type === "SUCCESS") {
return {
loading: false,
todo: action.payload,
error: false,
};
} else if (action.type === "ERROR") {
return {
loading: false,
todo: {},
error: true,
};
}
};
関数型で書かれているtodoIfというものに2つの引数があり、「state」「action」となっているのが分かります。
useReducerではstateとactionを使ってステートの管理を行います。
と言っても冒頭で話した通りuseReducerとuseStateは同じことをしているので、単純に書き方が違うだけと思ってもらって良いでしょう。
stateとactionとは何者?
stateはuseStateでいうステートの初期値のことで、actionはuseStateでいうセット関数のことです。
(例)
const [loading, setLoading] = useState(false);
false→useReducerではstateに当たる
setLoading→useReducerではactionに当たる
stateはuseStateのステートの初期値に当たると言いましたが、今回のプログラムのように複数のステートを管理しているためオブジェクトでまとめるルールがあります。
今回はloading、todo、errorと3種類のステートが必要でしたね。
// ステートが複数ある場合にはオブジェクト型でまとめて新しく定義しておくルールがある
export const init = {
loading: false,
todo: {},
error: false,
};
~省略~
続いてactionですが、actionのなかには「type」「payload」というプロパティが含まれています。
簡単に言い換えると、
・type:状態
・payload:データ
となります。
useReducerに限らずReact、Reduxでは「どういう状況でどんなデータを扱うか?」ということが大きな目標になっています。
useReducerも漏れなく同じ考え方になっていて「どういう状況(type)でどんなデータ(payload)を扱うか?」という内容です。
「type」「payload」というと慣れない単語に聞こえるのですが、そもそもの考え方はReactの基本を別の言葉で言い換えているだけなんですね。
typeとpayloadは何をしているのか?
reducer.jsの続きを見ていきます。
todoIfのなかで条件分岐が使われていて、条件の内容が「action.type」となっていますね。
「type:状態」が条件になっていて、文字列で「状態の名前」を設定します。
今回のプログラムでは「START」「SUCCESS」「ERROR」としていて、基本的に変数や関数の名前と同じでパッと見て分かりやすい名前が好ましいです。
~省略~
export const todoIf = (state, action) => {
// actionのなかにあるtypeには文字列で「状態」を設定できる
if (action.type === "START") {
// APIを取得し始めたときの状態
return {
loading: true,
todo: {},
error: false,
};
} else if (action.type === "SUCCESS") {
// APIを取得し終わったときの状態
return {
loading: false,
todo: action.payload,
error: false,
};
} else if (action.type === "ERROR") {
// APIを取得できなかったときの状態
return {
loading: false,
todo: {},
error: true,
};
}
};
また条件分岐の中身については管理しているステートがどのように変化するかを記載します。
// 3種類のステートの初期値がどのように変わるのか?
export const init = {
loading: false,
todo: {},
error: false,
};
↓↓↓
~省略~
// action.typeによってinitの値が変わる
return {
loading: false,
todo: action.payload,
error: false,
};
~省略~
「type」の意味合いがザックリ理解できたかと思います。
またactionの中にはtypeのほかにpayloadがあり、「payload:データ」というように説明していました。
つまりfetchされて取得できたデータがpayloadになるわけですね。
今回のプログラムでいう「データ」はAPIの取得結果になりますので、todoにpayloadが必要になります。
~省略~
export const todoIf = (state, action) => {
~省略~
} else if (action.type === "SUCCESS") {
return {
loading: false,
// todoにはAPIの取得結果が入る想定なので、action.payloadとなる
todo: action.payload,
error: false,
};
~省略~
};
Reduxになると突然switchが登場する理由
ここでもう一度reducer.jsの全体像を見返してみます。
export const init = {
loading: false,
todo: {},
error: false,
};
export const todoIf = (state, action) => {
if (action.type === "START") {
return {
loading: true,
todo: {},
error: false,
};
} else if (action.type === "SUCCESS") {
return {
loading: false,
todo: action.payload,
error: false,
};
} else if (action.type === "ERROR") {
return {
loading: false,
todo: {},
error: true,
};
}
};
状態を条件分岐で記載しているのですが、プログラムの世界では上記のように「if文をelse ifで長く書くのは良くない」という考え方があります。
初学者の方には中々分かりずらい考え方かと思いますが、プログラミングスクールや動画教材では必ず触れられるものです。
今回のプログラムで扱っている状態は「START」「SUCESS」「ERROR」の3種類だけなのですが、これがもし5種類くらいになると確かに見え方は悪くなりそうです。
そういった背景からuseReducerでは条件分岐をswitchで書くというルールになっています。
Reduxを勉強し始めたときにswitchを使ったコードを多く見かける理由はこういうことだったんですね。
switch文の書き方が初めての方は、Youtubeで解説しているのでご覧ください。
それではreducer.jsの条件分岐をswitchで書き直してみます。
export const init = {
loading: false,
todo: {},
error: false,
};
//ここから修正
export const todoSwitch = (state, action) => {
switch (action.type) {
case "START":
return {
loading: true,
todo: {},
error: false,
};
case "SUCCESS":
return {
loading: false,
todo: action.payload,
error: false
};
case "ERROR":
return {
loading: false,
todo: {},
error: true,
};
default:
return state;
}
};
学習教材やYoutube動画でよく見かけるコードになりましたね、Web制作からReactに手を出した方であれば「何この書き方?」となっていたんではないでしょうか?
書き方が違うだけで条件分岐をしているだけです。
色んなことを解説してきましたが、ここでもう一度reducer.jsを書く目的を見直しておくと「複数のステートを管理する専用のファイル」でしたね。
useReducerを使った状態管理では、reducer.jsでステートをまとめておいて、各コンポーネントはreducer.jsからステートを取り込むという方法になります。
そのためinit(ステートの初期状態)とtodoSwitch(ステートの更新関数)はexportでエクスポートしてあります。
//エクスポート
export const init = {
loading: false,
todo: {},
error: false,
};
//エクスポート
export const todoSwitch = (state, action) => {
switch (action.type) {
case "START":
return {
loading: true,
todo: {},
error: false,
};
case "SUCCESS":
return {
loading: false,
todo: action.payload,
error: false
};
case "ERROR":
return {
loading: false,
todo: {},
error: true,
};
default:
return state;
}
};
useReducerで管理されたステートの利用方法
それではコンポーネント側でreducer.jsのステートを使う方法を解説していきます。
先ほどのコンポーネントで使っていたFetchApi.jsを以下のような形に変更します。
import React, { useReducer } from "react";
import { init, todoSwitch } from "./reducer/reducer";
const FetchApiReducer = () => {
const [state, dispatch] = useReducer(todoSwitch, init);
const fetchTodos = () => {
dispatch({ type: "START" });
fetch("https://jsonplaceholder.typicode.com/todos/1")
.then((res) => res.json())
.then((data) => {
dispatch({ type: "SUCCESS", payload: data });
})
.catch((err) => {
dispatch({ type: "ERROR" });
});
};
return (
<>
<div>
<button onClick={fetchTodos}>
{state.loading ? "処理中" : "クリック"}
</button>
<p>{state.todo?.title}</p>
<span>{state.error && "エラーです"}</span>
</div>
</>
);
};
export default FetchApiReducer;
まずreducer.jsからエクスポートされたものをインポートしてuseReducerの引数に指定します。
useReducerもuseState同様にReactからインポートしておく必要があるので注意してください。
またreducer.jsからインポートしたinitとtodoSwitchはそれぞれ「state」「dispatch」という名前で分割代入で取り出すルールになっています。
// useReducerをインポートしておく
import React, { useReducer } from "react";
// reducer.jsからinit(ステートの初期値)とtodoSwitch(ステートの更新関数)をインポートしておく
import { init, todoSwitch } from "./reducer/reducer";
const FetchApiReducer = () => {
// useReducerの引数にinitとtodoSwitchを指定する。
// reducer.jsで定義したステートや更新関数を使うにはstate,dispatchに分割代入する
const [state, dispatch] = useReducer(todoSwitch, init);
~省略~
ここでまた新しい単語で「state」「dispatch」が登場しましたが、こちらもuseStateの書き換えになっているので難しく考える必要はありません。
「単語が変わっただけなんだな」で大丈夫です。
// useStateの場合
const [loading, setLoading] = useState(false);
// useReducerの場合
const [state, dispatch] = useReducer(todoSwitch, init);
とはいえ書き方は覚えておく必要があります。
dispatchの役割とは?
まずdispatchは以下のようにして書いていきます。
~省略~
const fetchTodos = () => {
// dispatchはオブジェクト型で、typeとpayloadを指定する
dispatch({ type: "START" });
fetch("https://jsonplaceholder.typicode.com/todos/1")
.then((res) => res.json())
.then((data) => {
// dispatchはオブジェクト型で、typeとpayloadを指定する
dispatch({ type: "SUCCESS", payload: data });
})
.catch((err) => {
// dispatchはオブジェクト型で、typeとpayloadを指定する
dispatch({ type: "ERROR" });
});
};
~省略~
dispatchはreducer.jsのなかでいうactionに相当するものになっているため、typeとpayloadを持っています。
もう一度復習ですが、
・actionはステートのセット関数
・typeは状態のことでswitch文で文字列で定義
・payloadはtype(状態)ごとのデータ
でしたね。
念のため、useStateでの書き方と比べてみましょう。
// uesStateでの書き方
const fetchTodos = () => {
setLoading(true);
setError(false);
fetch("https://jsonplaceholder.typicode.com/todos/1")
.then((res) => res.json())
.then((data) => {
setLoading(false);
setTodo(data);
})
.catch((err) => {
setLoading(false);
setError(true);
});
};
// useReducerでの書き方
const fetchTodos = () => {
dispatch({ type: "START" });
fetch("https://jsonplaceholder.typicode.com/todos/1")
.then((res) => res.json())
.then((data) => {
dispatch({ type: "SUCCESS", payload: data });
})
.catch((err) => {
dispatch({ type: "ERROR" });
});
};
dispatchと聞くと馴染みのない単語に見えますが、よくよく見てみるとuseStateで言うと「setLoading」「setError」などのステートを更新するセット関数と同じ役割なんですね。
またdispatchの引数はオブジェクト型で「type(状態)」「payload(データ)」を指定しますが、payloadについては「そもそもデータがない想定の状態」があるので省略することができます。
上記のコードだとAPIを呼び始めた時(type === “START”)では、データなんて無いのでpayloadは書いていません。
なぜstate.loadingという書き方なのか?
続きを見ていきます、「state」の書き方は以下のように書いていきます。
~省略~
return (
<>
<div>
<button onClick={fetchTodos}>
// reducer.jsでステートはオブジェクト型で管理していたので、state.〇〇という書き方になる
{state.loading ? "処理中" : "クリック"}
</button>
// reducer.jsでステートはオブジェクト型で管理していたので、state.〇〇という書き方になる
<p>{state.todo?.title}</p>
// reducer.jsでステートはオブジェクト型で管理していたので、state.〇〇という書き方になる
<span>{state.error && "エラーです"}</span>
</div>
</>
);
};
export default FetchApiReducer;
画面に出力する部分で肝心のステート変数を使っていきます。
こちらもuseStateのときと書き方を比べておきましょう。
// useStateでの書き方
return (
<>
<div>
<button onClick={fetchTodos}>{loading ? "処理中" : "クリック"}</button>
<p>{todo?.title}</p>
<span>{error && "エラーです"}</span>
</div>
</>
);
};
export default FetchApi;
// useReducerでの書き方
return (
<>
<div>
<button onClick={fetchTodos}>
{state.loading ? "処理中" : "クリック"}
</button>
<p>{state.todo?.title}</p>
<span>{state.error && "エラーです"}</span>
</div>
</>
);
};
useReducerの書き方だと「state.〇〇」となっていますね。
こちらreducer.jsを思い出してみると分かるのですが、ステートは複数あるのでオブジェクトで管理していましたよね。
export const init = {
loading: false,
todo: {},
error: false,
};
~省略~
そもそもuseReducerでは全てのステートをオブジェクトで一括管理するルールになっていたため、オブジェクトの中から取り出す必要があるという訳なんです。
もう一度全体のコードを見ておきます。
import React, { useReducer } from "react";
import { init, todoSwitch } from "./reducer/reducer";
const FetchApiReducer = () => {
const [state, dispatch] = useReducer(todoSwitch, init);
const fetchTodos = () => {
dispatch({ type: "START" });
fetch("https://jsonplaceholder.typicode.com/todos/1")
.then((res) => res.json())
.then((data) => {
dispatch({ type: "SUCCESS", payload: data });
})
.catch((err) => {
dispatch({ type: "ERROR" });
});
};
return (
<>
<div>
<button onClick={fetchTodos}>
{state.loading ? "処理中" : "クリック"}
</button>
<p>{state.todo?.title}</p>
<span>{state.error && "エラーです"}</span>
</div>
</>
);
};
export default FetchApiReducer;
「dispatch」「useReducer」など新しい単語になっているだけで、全体的な構成はuseStateとそんなに違わないですね。
もちろん最初は上手く扱えないと思いますが、反復練習していけば身に付くでしょう。
なぜReduxが難しく感じるのか?
長い時間をかけてuseReducerの使い方を説明してきました。
useReducerの使い方をマスターした段階で、ぜひもう一度Reduxに関するドキュメントやブログ記事を読み直して見て頂きたいです。
実は本記事では「Reduxとは?」という話を一切していません。
しかしReduxについてググったり学習しているときに登場するコードはすべてここまでの文章の中で登場しています。
useReducerの使い方を知れば、結果的にReduxのことも理解したことになるんですね。
もちろん公式ドキュメントをいきなり読んで理解できた方は素晴らしいです、そのまま進んでもらうと良いでしょう。
そういった方がいる一方で自分のように正面から学習しても理解できなかった人は少なくないのではと思ったため本記事を書きました。
またReduxを勉強していると以下のような図に遭遇すると思います。
自分も含め多くの初学者の方はこの図を見て「分からん。。。」となっています。
理由は簡単で「馴染みのない単語」が出てきたからです。
と言ってもここまで説明してきたように、結局はuseStateと一緒なんですね。
例えば図の中にある「Reducer」というものですが、この図だけでは「Reducerって何?」だと思います。
実はJavaScriptでreduceという関数があるのをご存知でしょうか?
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/reduce
簡単に言うと「配列の合算」をするための関数で、上の図でReducerの前に2本の矢印が合体しているのが分かります。
「Reducer」というのは既存のメソッドである「reduce」から来ている単語で、意味としては「古いステートと新しいステートを合算する」という内容です。
さすがにここまで来るとマニアックすぎるのでreduceについて気になる方は別の記事で解説しているのでご覧ください。
結局のところReactフック、状態管理ライブラリ、何を使えば良いのか?
状態管理ライブラリの代表としてReduxを挙げていましたが、実際には他にも同じようなライブラリがあります。
またReactフックだけでもReduxと同じような仕組みを作成できます。
そうなってくると初心者は経験が足りませんから、何を使って実装するのか問題になるはずです。
結論としては「まずはReactフックを使って、限界がきたらReduxなど状態管理ライブラリを導入する」というマインドが堅実です。
なぜそう言えるのかはReactの歴史を知る必要があります。
useStateとuseEffectからスタート
まずは状態という概念が登場してユーザーの動作を履歴のようにストックすることがReact上で出来るようになりました。
しかし複雑な仕様やサイトのページ数が多くなるにつれuseStateで宣言するステートの数が多くなります。
さらにコンポーネントファイルで分割しているため、同じステートはpropsで渡し合うことになり、useStateで宣言するステートの数に比例してpropsの数も増えました。
後で単一のコンポーネントファイルだけを見返した時に、「このpropsはどこから始まっているのか?」がわかりにくくなります。
またバケツリレーのようにpropsを渡すため専用のコンポーネントファイルまで作成している可能性もあります。
使いもしないステートを渡す中継役として存在するコンポーネントファイルの存在がさらに複雑に感じさせるわけです。
useContextの登場
そこでprops自体を無くす、極小にするためにuseContextというフックが追加されました。
useContextの特徴はステートを共有する際にProviderという専用のタグで入れ子のように囲む書き方です。
つまり入れ子の中には複数のコンポーネントを入れられるため、1回で複数のファイルにステートを共有することが出来るようになりました。
propsを渡すためだけの中継役は不要になりますし、入れ子の書き方によって親子関係も明瞭になりました。
しかしこちらもプロジェクトが大規模になることで入れ子が複雑になります。
入れ子の子供側もProviderを使って別のファイルを囲むことが出来るためです。
そのため入れ子にして共有したステートが実際どこまで共有されて使用されているのか不明確になり始めたのです。
「ボタンをクリックしたらloadingをtrueにして、データを取得したらloadingをfalseに切り替える」といった、時系列に応じたステートの整合性を取ることも困難になりました。
useReducerの登場
そこで登場したのがuseReducerです。
そもそも各ファイルにステートがあることが原因とされて、ステートを管理する専用の1、2個のファイルを用意して、各ファイル上ではステートを宣言したり渡したりすることを無くしたのです。
これにより各ファイルは画面に表示する部分をメインにできて、ステートを管理する1、2個のファイルを見ればステートの修正もしやすくなったのです。
またswitch文を使ったstateとactionを並列に記載する書き方によって、時系列によるステートの整合性も捉えやすくなりました。
このように各ファイルからステートとその関数を切り離して、専用のファイルだけで管理することを「グローバルステート」とも呼びます。
ReduxやRecoilといった状態管理ライブラリの根幹にはグローバルステートの考え方がもれなく入っています。
URLにステートをプッシュすることも
加えて近年のトレンドとしてはURLにクエリの一部としてステートをプッシュして、URLから状態を読み取る手法もあります。
ReactQueryという有名なライブラリがあり、URLを起点にしたステート管理はREST APIやバックエンド言語に似たような考え方なので分かりやすく人気があります。
またReactのフレームワークであるNext.jsもバックエンドとの連携をしやすくしており、Next.jsとReactQueryの相性も良いとされています。
初期のように全てのステートを画面を構成するコンポーネントファイルだけで管理するのではなく、サーバーとも分け合うことでコンポーネントファイル内の煩雑さを解消できました。
といった具合にReactの発展とともにReactフックのバリエーションも増えて、それに付随するライブラリも開発されてきたわけです。
しかし初心者の方がそれら全てを身につけることは現実的ではありません。
そもそも状態管理ライブラリは基本のReactフックよりも学習難易度が高いですよね。
最適解やトレンドを追うべきではあるものの、それを自分が扱えるか?ということは別の話です。
使えもしないのにReduxに手を出して挫折するのは本末転倒です。
そのため初心者はReactフックから始めることが健全と言えます。