はじめに
ReactでSPAを実装する時に、Reduxも使ってstateを管理することが多いと思います。私も実際その組み合わせにMUIを加えてアプリケーションを実装していたのですが、思わぬところで問題が発生して余計な時間を取られてしまったので、その詳細を記載しておこうと思います。
アプリケーションの作成
今回はViteを使って雛形のReact Applicationを作成します。
$ npm create vite@latest react-router-redux-mui -- --template react
Scaffolding project in /root/react-router-redux-mui...
Done. Now run:
cd react-router-redux-mui
npm install
npm run dev
$ cd react-router-redux-mui/
$ npm i
added 86 packages, and audited 87 packages in 17s
9 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities
アプリケーションが作成されたら、以下のコマンドで起動できます。
$ npm run dev
> react-router-redux-mui@0.0.0 dev
> vite
vite v2.9.14 dev server running at:
> Local: http://localhost:3000/
> Network: use `--host` to expose
ready in 1142ms.
ログにあるようにlocalhostの3000番にアクセスすればアプリケーションの動作を確認できます。
起動できることが確認できたら、余分なソースを削除し、ESLintの構成もしておきます。
$ npx eslint --init
MUI
まずはMUIを導入します。こちらの手順に沿って導入します。
$ npm install @mui/material @emotion/react @emotion/styled @mui/icons-material
index.html
も更新します。
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"/>
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons"/>
これでMUIを使う準備ができました。App.js
を書き換えてみます。
import { Box, Button } from "@mui/material";
import React from "react";
import "./App.css";
function App() {
return (
<Box className={"App"}>
Hello World!
<Button variant="contained">Click</Button>
</Box>
);
}
export default App;
React Router
次にReact Routerを使うための構成を行います。Quick Startに従って構成していきます。
$ npm i react-router-dom@6
新たにMenu.jsx
というページを作っておきます。
import React from "react";
import { Box } from "@mui/system";
import { Link } from "@mui/material";
export default function Menu() {
return (
<Box>Menu Page <Link href="/">Home</Link></Box>
);
}
存在しないパスを指定した時のページも作っておきます。
import React from "react";
import { Link, Stack, Box } from "@mui/material";
export default function NoRoute() {
return (
<Stack direction="row" spacing={3}>
<Box>Not Found</Box>
<Link href="/">Back to Home</Link>
</Stack>
);
}
App.jsx
もMenu.jsx
へ飛べるように変更しておきます。
import { Box, Button, Link, Stack } from "@mui/material";
import React from "react";
import "./App.css";
function App() {
return (
<Stack direction="row" spacing={3} className={"App"}>
<Box>Hello World!</Box>
<Button variant="contained">Click</Button>
<Link href="/menu">Menu</Link>
</Stack>
);
}
export default App;
main.jsx
にReact Routerを使ったルーティングの設定を記述します。
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import "./index.css";
import App from "./App";
import NoRoute from "./NoRoute";
import Menu from "./Menu";
ReactDOM.createRoot(document.getElementById("root")).render(
<BrowserRouter>
<Routes>
<Route path="/" element={<App/>} />
<Route path="/menu" element={<Menu/>} />
<Route path="*" element={<NoRoute/>} />
</Routes>
</BrowserRouter>
);
トップページのリンクをクリックすると、/menu
に遷移してページが表示されるのがわかると思います。
Redux/Redux Toolkit
Reduxを導入します。素で構成してもいいのですが、公式でもRedux Toolkitを使って構成するのが推奨なようなので、Redux ToolkitのQuick Startに沿って構成してみます。
$ npm install @reduxjs/toolkit react-redux
added 5 packages, and audited 275 packages in 2s
70 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities
必要なモジュールをインストールしたら、まずはstoreを用意します。
import { configureStore } from "@reduxjs/toolkit";
export const store = configureStore({
reducer:{}
});
main.jsx
で上記のstore
を組み込みます。
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import "./index.css";
import App from "./App";
import NoRoute from "./NoRoute";
import Menu from "./Menu";
import { Provider } from "react-redux";
import { store } from "./store";
ReactDOM.createRoot(document.getElementById("root")).render(
<Provider store={store}>
<BrowserRouter>
<Routes>
<Route path="/" element={<App/>} />
<Route path="/menu" element={<Menu/>} />
<Route path="*" element={<NoRoute/>} />
</Routes>
</BrowserRouter>
</Provider>
);
次にactionやreducerを作るため、sliceを作っていきます。ここではQuick StartにあるcounterSlice
をほぼそのまま使っています。
import { createSlice } from "@reduxjs/toolkit";
const initialState = {
value: 0,
};
export const counterSlice = createSlice({
name: "counter",
initialState,
reducers: {
increment: (state) => {
// Redux Toolkit allows us to write "mutating" logic in reducers. It
// doesn't actually mutate the state because it uses the Immer library,
// which detects changes to a "draft state" and produces a brand new
// immutable state based off those changes
state.value += 1;
},
decrement: (state) => {
state.value -= 1;
},
incrementByAmount: (state, action) => {
state.value += action.payload;
},
},
});
// Action creators are generated for each case reducer function
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
export default counterSlice.reducer;
これを使ってApp.js
にカウンターを実装してみます。
import { Box, Button, Link, Stack } from "@mui/material";
import React from "react";
import { useDispatch, useSelector } from "react-redux";
import "./App.css";
import { decrement, increment } from "./counterSlice";
function App() {
const count = useSelector((state) => state.counter.value);
const dispatch = useDispatch();
return (
<Stack direction="row" spacing={3} className={"App"}>
<Box>Count: {count}</Box>
<Button variant="contained" onClick={() => dispatch(increment())}>+1</Button>
<Button variant="contained" onClick={() => dispatch(decrement())}>-1</Button>
<Link href="/menu">Menu</Link>
</Stack>
);
}
export default App;
別のページで使う
しかし同じページでカウントが増減できてもそれは普通にReactのstate等でも可能なので、Reduxを使ってる価値はほぼありません。ですので別のページから同じカウントを参照してみます。
遷移先であるMenu.jsx
ページで先ほどと同じcount
を表示させてみます。
import React from "react";
import { Box } from "@mui/system";
import { Link } from "@mui/material";
import { useSelector } from "react-redux";
export default function Menu() {
const count = useSelector((state) => state.counter.value);
return (
<Box>Count: {count} <Link href="/">Home</Link></Box>
);
}
するとどうでしょう。
App
の方でカウントを変更してもMenu
の方に遷移するとリセットされて0になってしまいました。
原因
この原因に気づくまでほぼ丸一日を要しました(!)が、わかってみると単純でした。原因はこれでした。
<Link href="/menu">Menu</Link>
正解は
<Link to="/menu">Menu</Link>
です。これだとただのattributeの選び間違いのようにも見えますが、実際は<Link>
の実体が異なります。
動かない方は
import { Link } from "@mui/material";
正解は
import { Link } from "react-router-dom";
MUIのLinkは
The Link component allows you to easily customize anchor elements with your theme colors and typography styles.
ですので基本的に<a>
タグそのものになり、href
に指定されたURLに遷移することになります。なので基本的にはそのURLを直叩きした時と同様、ページ全体がロードされるようです。それに対してreact-router-domのLinkはSPAの中でのページ遷移であり、ブラウザのURLは変わっているもののページ全体がロードされることはありません。なのでreduxのstateもそのまま保持されているようです。
この変更を施して実際にMenuページへ移動してみると無事カウントが表示されました!
このページにはカウントを変更するボタンがないので、他のページ(コンポーネント)で変更された値を正しく参照できていることがわかると思います。
MUIのcomponentを使ってreact-router-domのroutingを使うには、以下のようにすれば可能です。
<Button variant="contained" component={Link} to="/">Home</Button>
こうするとMUIのデザインのボタンでreact-router-domのroutingを行うことができます。
まとめ
React Route + Redux + MUI の組み合わせで生じた問題について記述しました。原因がわかってみれば単純だったのですが、なにぶん同じ名前のコンポーネントだったので気づくのに時間がかかってしまいました。同じ問題に直面してる方の一助になれば幸いです。
Caveat
本来はreact-routerとreduxを一緒に使うには、react-router-reduxやその後継のようなconnected-react-routerなどの導入が必要なようなのですが、少なくとも上記の使い方であれば導入しなくても大丈夫なようです。ですが問題が起きる可能性はあるかもしれません。