【React × TypeScript】ReduxのStateを更新時にリロードなしで画面描画する方法
はじめに
先日Notionのクローンアプリを作成時に、記事タイトル更新とお気に入り機能を実装したのですが、リロードしないと画面に反映することができず、もどかしかさを感じましたため、本記事に解消方法を記載します。
アプリ概要
:::note info
ルーティング設定
:::
以下が、react-router-dom
のルーティングになっています。
import { BrowserRouter, Route, Routes } from "react-router-dom";
import "./App.css";
import AuthLayout from "./components/layout/AuthLayout";
import Login from "./components/pages/Login";
import Register from "./components/pages/Register";
import { createTheme, CssBaseline, ThemeProvider } from "@mui/material";
import AppLayout from "./components/layout/AppLayout";
import Home from "./components/pages/Home";
import MemoPage from "./components/pages/Memo";
const App = () => {
const theme = createTheme({
palette: { primary: { main: "#0000ff" } },
});
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<BrowserRouter>
<Routes>
<Route path="/" element={<AuthLayout />}>
<Route path="login" element={<Login />}></Route>
<Route path="register" element={<Register />}></Route>
</Route>
<Route path="/" element={<AppLayout />}>
<Route index element={<Home />} />
<Route path="memo" element={<Home />} />
<Route path="memo/:memoId" element={<MemoPage />} />
</Route>
</Routes>
</BrowserRouter>
</ThemeProvider>
);
};
export default App;
こちらが、アプリケーションのメインのコンポーネントです。
ルーティング設定と合わせてご覧ください。
:::note warn
<Route path="/" element={<AppLayout />}>
<Route index element={<Home />} />
<Route path="memo" element={<Home />} />
<Route path="memo/:memoId" element={<MemoPage />} />
</Route>
import { Box } from "@mui/material";
import React, { useEffect } from "react";
import { Outlet, useNavigate } from "react-router-dom";
import authUtils from "../../utils/authUtils";
import Sidebar from "../common/Sidebar";
import { useDispatch } from "react-redux";
import { setUser } from "../../redux/features/userSlice";
const AppLayout = () => {
const navigate = useNavigate();
const dispatch = useDispatch();
// ページ遷移ごとに発火する
useEffect(() => {
// JWTを持っているかの確認
const checkAuth = async () => {
// 認証チェック
const user = await authUtils.isAuthenticated();
// ユーザーが存在しない場合、ログインページにリダイレクト
if (!user) {
navigate("/login");
} else {
// ユーザーを保存する
dispatch(setUser(user));
}
};
checkAuth();
}, [navigate]);
return (
<div>
<Box sx={{ display: "flex" }}>
<Sidebar />
<Box sx={{ flexGrow: 1, p: 1, width: "max-content" }}>
<Outlet />
</Box>
</Box>
</div>
);
};
export default AppLayout;
import { LoadingButton } from "@mui/lab";
import { Box } from "@mui/material";
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import memoApi from "../../api/memoApi";
import { useSelector } from "react-redux";
import { RootState } from "../../redux/store";
import { setMemo } from "../../redux/features/memoSlice";
import { useDispatch } from "react-redux";
import { Memo } from "../../types/Memo";
const Home = () => {
const navigate = useNavigate();
const dispatch = useDispatch();
const [loading, setLoading] = useState<boolean>(false);
const memos: Memo[] = useSelector((state: RootState) => state.memo.value);
const createMemo = async () => {
try {
setLoading(true);
const res = await memoApi.create();
console.log(res);
const newMemos = [...memos, res.data];
dispatch(setMemo(newMemos));
navigate(`/memo/${res.data._id}`);
} catch (err) {
alert(err);
} finally {
setLoading(false);
}
};
return (
<Box
sx={{
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<LoadingButton
variant="outlined"
onClick={() => createMemo()}
loading={loading}
>
最初のメモを作成
</LoadingButton>
</Box>
);
};
export default Home;
:::
簡単に述べるとログインして記事がない場合は、HomeコンポーネントのJSXを表示し、メモ作成を押下すると、作成されたメモの詳細ページの遷移するようにしています。
そして、AppLayoutコンポーネントのようにSidebarとHomeがあるような画面となります。
以下が、Sidebarコンポーネントです。
import LogoutOutlinedIcon from "@mui/icons-material/LogoutOutlined";
import AddBoxOutlinedIcon from "@mui/icons-material/AddBoxOutlined";
import {
Box,
Drawer,
IconButton,
List,
ListItemButton,
Typography,
} from "@mui/material";
import React, { useState, useEffect } from "react";
import assets from "../../assets";
import { useNavigate, useParams } from "react-router-dom";
import { useSelector } from "react-redux";
import { RootState } from "../../redux/store";
import { Link } from "react-router-dom";
import memoApi from "../../api/memoApi";
import { useDispatch } from "react-redux";
import { setMemo } from "../../redux/features/memoSlice";
import { Memo } from "../../types/Memo";
const Sidebar = () => {
const dispatch = useDispatch();
const navigate = useNavigate();
const user = useSelector((state: RootState) => state.user.value);
const memos: Memo[] = useSelector((state: RootState) => state.memo.value);
const [activeIndex, setActiveIndex] = useState<number>(0);
const [activeFavIndex, setActiveFavIndex] = useState<number>(0);
// URLのIDを取得
const { memoId } = useParams();
const logout = () => {
localStorage.removeItem("token");
navigate("/login");
};
useEffect(() => {
const getMemos = async () => {
try {
const res = await memoApi.getAll();
dispatch(setMemo(res.data));
} catch (err) {
alert(err);
}
};
getMemos();
}, [dispatch]);
// 依存配列をnavigateにすることで画面遷移のたびに発火する
useEffect(() => {
const activeIndex = memos.findIndex(
(memo: Memo) => !memo.favorite && memo._id === memoId
);
setActiveIndex(activeIndex);
const activeFavIndex = memos
.filter((filterMemo: Memo) => filterMemo.favorite === true)
.findIndex((memo: Memo) => memo._id === memoId);
setActiveFavIndex(activeFavIndex);
}, [navigate]);
const addMemo = async () => {
try {
const res = await memoApi.create();
const newMemos = [...memos, res.data];
dispatch(setMemo(newMemos));
navigate(`memo/${res.data._id}`);
} catch (err) {
alert(err);
}
};
return (
<Drawer
container={window.document.body}
variant="permanent"
open={true}
sx={{ width: 250, height: "100vh" }}
>
<List
sx={{
width: 250,
height: "100vh",
backgroundColor: assets.colors.secondary,
}}
>
<ListItemButton onClick={logout}>
<Box
sx={{
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Typography variant="body2" fontWeight="700">
{user.username}
</Typography>
<IconButton>
<LogoutOutlinedIcon />
</IconButton>
</Box>
</ListItemButton>
<Box sx={{ paddingTop: "10px" }}></Box>
<ListItemButton>
<Box
sx={{
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Typography variant="body2" fontWeight="700">
お気に入り
</Typography>
</Box>
</ListItemButton>
{memos
.filter((memo) => memo.favorite === true)
.map((favMemo, index) => (
<ListItemButton
key={favMemo._id}
sx={{ pl: "20px" }}
component={Link}
to={`/memo/${favMemo._id}`}
selected={index === activeFavIndex}
>
<Typography>
{favMemo.icon} {favMemo.title}
</Typography>
</ListItemButton>
))}
<Box sx={{ paddingTop: "10px" }}></Box>
<ListItemButton>
<Box
sx={{
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Typography variant="body2" fontWeight="700">
プライベート
</Typography>
<IconButton onClick={() => addMemo()}>
<AddBoxOutlinedIcon fontSize="small" />
</IconButton>
</Box>
</ListItemButton>
{memos.map((memo, index) => (
<ListItemButton
key={memo._id}
sx={{ pl: "20px" }}
component={Link}
to={`/memo/${memo._id}`}
selected={index === activeIndex}
>
<Typography>
{memo.icon} {memo.title}
</Typography>
</ListItemButton>
))}
</List>
</Drawer>
);
};
export default Sidebar;
こちらはよくあるようなコンポーネントかと思いますので、説明は割愛します。
:::note info
以下がReduxで管理している記事のStateです
:::
まずはStore
のほうを記載します。
グローバルに管理したいReducer
を定義しています。
import { configureStore } from "@reduxjs/toolkit";
import userReducer from "./features/userSlice";
import memoReducer from "./features/memoSlice";
export const store = configureStore({
reducer: { user: userReducer, memo: memoReducer },
});
export type RootState = ReturnType<typeof store.getState>;
次にSlice
ですが、ここでStateの保持と更新を実行しています。
これをStore
でReducer
として渡す(定義する)ようにすることでRedux上でStateを管理し、グローバルスコープとすることができます。
import { createSlice } from "@reduxjs/toolkit";
const initialState = { value: [] };
export const memoSlice = createSlice({
name: "memo",
initialState,
reducers: {
setMemo: (state, action) => {
state.value = action.payload;
},
},
});
export const { setMemo } = memoSlice.actions;
export default memoSlice.reducer;
State更新時に即時画面に反映させる
ようやく、本記事の主題となるわけですが、この処理を実装しているのが、記事詳細のコンポーネントになります。
以下が、そのコンポーネントです。
import StarBorderOutlinedIcon from "@mui/icons-material/StarBorderOutlined";
import StarIcon from "@mui/icons-material/Star";
import DeleteOutlinedIcon from "@mui/icons-material/DeleteOutlined";
import { Box, IconButton, TextField } from "@mui/material";
import React, { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import memoApi from "../../api/memoApi";
import { useDispatch } from "react-redux";
import { useSelector } from "react-redux";
import { RootState } from "../../redux/store";
import { Memo } from "../../types/Memo";
import { setMemo } from "../../redux/features/memoSlice";
import EmojiPicker from "../common/EmojiPicker";
const MemoPage = () => {
const dispatch = useDispatch();
const navigate = useNavigate();
const { memoId } = useParams<{ memoId?: string }>();
const [title, setTitle] = useState<string>("");
const [description, setDescription] = useState<string>("");
const [icon, setIcon] = useState<string>("");
const [isFavorite, setIsFavorite] = useState<boolean>(false);
const memos: Memo[] = useSelector((state: RootState) => state.memo.value);
const refresh = async () => {
try {
const res = await memoApi.getAll();
dispatch(setMemo(res.data));
} catch (err) {
alert(err);
}
};
useEffect(() => {
const getMemo = async () => {
try {
if (!memoId) return;
const res = await memoApi.getOne(memoId!);
setTitle(res.data.title);
setDescription(res.data.description);
setIcon(res.data.icon);
setIsFavorite(res.data.favorite);
} catch (err) {
alert(err);
}
};
memoId && getMemo();
}, [memos, memoId]);
let timer: any;
const timeout = 500;
const updateTitle = async (e: React.ChangeEvent<HTMLInputElement>) => {
clearTimeout(timer);
const newTitle = e.target.value;
setTitle(newTitle);
timer = setTimeout(async () => {
try {
if (!memoId) return;
await memoApi.update(memoId!, { title: newTitle });
} catch (err) {
alert(err);
}
}, timeout);
};
const confirmTitle = async (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key !== "Enter") return;
timer = setTimeout(async () => refresh(), timeout);
};
const updateDescription = async (e: React.ChangeEvent<HTMLInputElement>) => {
clearTimeout(timer);
const newDescription = e.target.value;
setDescription(newDescription);
timer = setTimeout(async () => {
try {
if (!memoId) return;
await memoApi.update(memoId!, {
description: newDescription,
});
} catch (err) {
alert(err);
}
}, timeout);
};
const favoriteMemo = async () => {
try {
if (!memoId) return;
const res = await memoApi.favorite(memoId!);
const newMemos = memos.map((memo) =>
memo._id === memoId ? res.data : memo
);
dispatch(setMemo(newMemos));
refresh();
} catch (err) {
alert(err);
}
};
const deleteMemo = async () => {
try {
if (!memoId) return;
await memoApi.delete(memoId!);
// メモをリロードなしで画面から削除するため、Reduxのmemosから削除したメモを除外し、新しく配列を作成
const newMemos = memos.filter((memo) => memo._id !== memoId!);
if (newMemos.length === 0) {
navigate("/");
} else {
navigate(`/memo/${newMemos[0]._id}`);
}
dispatch(setMemo(newMemos));
} catch (err) {
alert(err);
}
};
const onIconChange = async (newIcon: string) => {
let temp: Memo[] = [...memos];
// 詳細ページで該当のメモのindexを取得
const index = temp.findIndex((memo) => memo._id === memoId);
// アイコンのみ引数で受け取ったアイコンに更新
temp[index] = { ...temp[index], icon: newIcon };
setIcon(newIcon);
dispatch(setMemo(temp));
try {
await memoApi.update(memoId!, { icon: newIcon });
} catch (err) {
alert(err);
}
};
return (
<>
<Box
sx={{
display: "flex",
alignItems: "center",
width: "100%",
}}
>
<IconButton onClick={favoriteMemo}>
{isFavorite ? <StarIcon /> : <StarBorderOutlinedIcon />}
</IconButton>
<IconButton color="error" onClick={deleteMemo}>
<DeleteOutlinedIcon />
</IconButton>
</Box>
<Box sx={{ padding: "10px 50px" }}>
<Box>
<EmojiPicker icon={icon} onChange={onIconChange} />
<TextField
value={title}
placeholder="無題"
variant="outlined"
fullWidth
sx={{
".MuiOutlinedInput-input": { padding: 0 },
".MuiOutlinedInput-notchedOutline": { border: "none" },
".MuiOutlinedInput-root": { fontSize: "2rem", fontWeight: "700" },
}}
onChange={updateTitle}
onKeyUp={confirmTitle}
/>
<TextField
value={description}
placeholder="追加"
variant="outlined"
fullWidth
multiline
sx={{
".MuiOutlinedInput-input": { padding: 0 },
".MuiOutlinedInput-notchedOutline": { border: "none" },
".MuiOutlinedInput-root": { fontSize: "1rem" },
}}
onChange={updateDescription}
/>
</Box>
</Box>
</>
);
};
export default MemoPage;
今回の本題となる「いいね」を行う関数がこちらになります。
:::note info
いいね機能を行う関数(ポイントとなるコードも合わせて記載)
:::
useEffect(() => {
const getMemos = async () => {
try {
const res = await memoApi.getAll();
dispatch(setMemo(res.data));
} catch (err) {
alert(err);
}
};
getMemos();
}, [dispatch]);
const memos: Memo[] = useSelector((state: RootState) => state.memo.value);
const refresh = async () => {
try {
const res = await memoApi.getAll();
dispatch(setMemo(res.data));
} catch (err) {
alert(err);
}
};
const favoriteMemo = async () => {
try {
if (!memoId) return;
const res = await memoApi.favorite(memoId!);
const newMemos = memos.map((memo) =>
memo._id === memoId ? res.data : memo
);
dispatch(setMemo(newMemos));
refresh();
} catch (err) {
alert(err);
}
};
まず、わかりやすいMemo.tsx
のほうから説明します。
memos
はReduxで管理しているStateをコンポーネント内で使えるよう定義しているという内容になります。
次にrefresh関数
ですが、こちらが非常に重要で、これがないと即時に画面反映とはなりません。
やっていることはシンプルで、APIから記事の一覧を取得し、dispatch
することで、ReducerのState(memoSlice)を更新しています。
最後にfavoriteMemo関数
ですが、こちらは、Memoがfavorite
というプロパティをbool値でもっているため、IDを渡して、APIでfavoriteプロパティを更新する処理を行っています。
また、newMemos
では、現在の記事一覧をmapで回し、配列のデータで合致したデータをいいねした記事に置き換える処理になります。
それをdispatch
の引数になっている更新関数の引数に渡すことで、State(memoSlice)を更新するようにしています。
:::note info
dispatchしたならここまででいいのでは?
:::
こう思うかもしれませんし、もしくは
:::note info
いいねを保持するStateをMemoPageコンポーネントで定義して、
useEffectの依存配列にすればいいのでは?
:::
と思うと思います。
実際に私もそのような実装を試みましたが、それだとReduxとReact.useStateの挙動が合わさり、いいね時にSidebarにお気に入り表示された記事が元の一覧から消えるなどして、思った通りになりませんでした。
それもそのはずで、SidebarのuseEffect
に定義されている通りにしないとうまくいくはずはないことに気づきました。
useEffect(() => {
const getMemos = async () => {
try {
const res = await memoApi.getAll();
dispatch(setMemo(res.data));
} catch (err) {
alert(err);
}
};
getMemos();
}, [dispatch]);
つまり、再度サーバーからデータを取得してい、サーバーとフロントで同期をとるようにする必要があるということです。
そのため、再度APIから一覧取得を呼ぶことでSidebarのuseEffectが発火し、正しく動くというのが一連の流れです。
その他の更新も同様
以下はタイトルの更新になります。
let timer: any;
const timeout = 500;
const updateTitle = async (e: React.ChangeEvent<HTMLInputElement>) => {
clearTimeout(timer);
const newTitle = e.target.value;
setTitle(newTitle);
timer = setTimeout(async () => {
try {
if (!memoId) return;
await memoApi.update(memoId!, { title: newTitle });
} catch (err) {
alert(err);
}
}, timeout);
};
const confirmTitle = async (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key !== "Enter") return;
timer = setTimeout(async () => refresh(), timeout);
};
これらの処理はどちらもsetTimeoutで0.5秒待ってから行うようにしています。
(パフォーマンスの観点から連発で更新するのを防ぐため)
そして例のごとくrefresh
しています。
説明は同様のものとなるので割愛します。
Stateの概念はReactで超重要なので、まだまだ学習していきたいと感じる実装でした。
以上がReduxでのStateの即時画面反映の方法でした。
これよりいいやり方も全然あると思いますので、あくまで参考程度にしていただければと思います。
Discussion