😀

【React × TypeScript】ReduxのStateを更新時にリロードなしで画面描画する方法

2023/03/31に公開

はじめに

先日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>
AppLayout.tsx
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;
Home.tsx
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コンポーネントです。

Sidebar.tsx
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を定義しています。

store.ts
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の保持と更新を実行しています。
これをStoreReducerとして渡す(定義する)ようにすることでRedux上でStateを管理し、グローバルスコープとすることができます。

memoSlice.ts
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更新時に即時画面に反映させる

ようやく、本記事の主題となるわけですが、この処理を実装しているのが、記事詳細のコンポーネントになります。
以下が、そのコンポーネントです。

Memo.tsx
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
いいね機能を行う関数(ポイントとなるコードも合わせて記載)
:::

Sidebar.tsx
useEffect(() => {
    const getMemos = async () => {
      try {
        const res = await memoApi.getAll();
        dispatch(setMemo(res.data));
      } catch (err) {
        alert(err);
      }
    };
    getMemos();
}, [dispatch]);
Memo.tsx
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に定義されている通りにしないとうまくいくはずはないことに気づきました。

Sidebar.tsx
useEffect(() => {
    const getMemos = async () => {
      try {
        const res = await memoApi.getAll();
        dispatch(setMemo(res.data));
      } catch (err) {
        alert(err);
      }
    };
    getMemos();
}, [dispatch]);

つまり、再度サーバーからデータを取得してい、サーバーとフロントで同期をとるようにする必要があるということです。
そのため、再度APIから一覧取得を呼ぶことでSidebarのuseEffectが発火し、正しく動くというのが一連の流れです。

その他の更新も同様

以下はタイトルの更新になります。

Memo.tsx
  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