🐡

Reduxの基礎知識

2023/10/13に公開

はじめに

Reduxの基礎的な部分から、Redux Toolkitを使った、データの管理方法を書いていきます。

Reduxの概念

簡単に言うと、Reduxはデータを一つの場所で管理して、どこでも使えて楽だよねというものです。
各ファイルで同じデータを使うのであれば、そのファイルごとでデータを取得するのは面倒ですし、コードの量のも増えてしまいます。

Reduxの仕組み

以下の画像は、Reduxの仕組みを図で表したものです。
image.png

Store

データの保管庫です。
データを他のファイルで使用する場合は、storeからデータを取得してきます。

Reducer

Reducerとは、アプリケーションの状態を書き換える事が唯一できるものです。 Reduxの基本設計の1つであるSingle source of truth(ソースは1つだけ)でもあるようにStateを書き換える事ができるのはこのReducerのみです。(他のサイトからパクりました)

Actions

データを操作する時のメソッドと考えればいいかなと。

Dispatcher(Dispatch)

日本語だと送るとか送付するといった意味をもちます。データを操作する時には、dipatchを使って、どのようにデータを操作するか(action)を通知する役割をします。

State

storeのデータの状態を表すものです。

View

こちらはユーザーが目にする画面の部分です。

Reduxを使うメリット

メリットは以下のようになります。
・コードの可読性が上がる。
・機能追加にも容易に対応できる。
・考え方はシンプルなので、理解すれば使いやすい。

デメリット

・学習コスト
→仕組みや概念の理解が少し難しいかも
・コード量が増えてしまうことがある。

インストール

Reduxのインストール方法について書いていきます。

npm
npm install redux

npm install react-redux
yarn
yarn add redux

yarn add react-redux

ついでにReduxToolkitのインストールコマンドも書いておきます。

npm
npm install @reduxjs/toolkit
yarn
yarn add @reduxjs/toolkit

実践

ここからはReduxToolkitを使って、コードを書いていきます。
ReduxToolkitとは、Reduxを用いた開発を効率的に行うためのツールキットです。

Reduxと比べて、最大のメリットはコード量が減ることです。詳しくは下の構成図で説明させていただきます。他にも、可読性が上がることやTypeScriptとの相性がいいこともメリットです。

まずは、rootファイルに以下のコードを書きます。

import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
import { store } from "../src/store/store";
import { Provider } from "react-redux";

const root = ReactDOM.createRoot(
  document.getElementById("root") as HTMLElement
);
root.render(
  <Provider store={store}>
    <App />
  </Provider>
);

reportWebVitals();

これで、全てのファイルでデータを管理できるようになりました。

次にstoreを定義します。

import { configureStore } from "@reduxjs/toolkit";
import clipReducer from "./clipSlice";
import favoriteReducer from "./favoriteSlice";

export const store = configureStore({
  reducer: {
    clip: clipReducer,
    favorite: favoriteReducer,
  },
});

インポートされているsliceとは、ReduxToolkit特有のものです。
slice は、Reduxストアの状態(state)とアクション(action)を定義し、Reduxのリデューサー(reducer)を生成するための便利な方法です。Redux Toolkitは、Reduxコードをより効率的に記述できるようにするためのユーティリティを提供するライブラリで、slice はその一部です。

このStoreには、clipSliceとfacoriteSliceが定義されていますが、今回はclipSliceについて説明していきます。

import { createSlice } from "@reduxjs/toolkit";

const initialState = {
  clips: [],
};

export const clipSlice = createSlice({
  name: "clip",
  initialState,
  reducers: {
    addClip: (state, action) => {
      const newClip = action.payload;
      state.clips.push(newClip);
    },
    deleteClip: (state, action) => {
      const deletingClip = action.payload;
      const currentClip = state.clips;
      const filterClip = currentClip.filter(
        (clip) => clip.publishedAt !== deletingClip.publishedAt
      );
      state.clips = filterClip;
    },
  },
});

export const { addClip, deleteClip } = clipSlice.actions;

export default clipSlice.reducer;

このsliceでは、「addClip」と「deleteClip」というReducerが定義されています。
このReducerをView側から呼び出していきます。
以下は、記事をクリップするボタンのコンポーネントです。
メソッドをpropsで受け取っています。

import React, { FC } from "react";
import { faBookmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { clipButton } from "../types/clipButton";

// iconStyleをpropsとして受け取る
export const BookMarkButton: FC<clipButton> = (props) => {
  const { iconStyle, articleClip } = props;

  return (
    <button className="ml-1 hover:opacity-75" onClick={articleClip}>
      <FontAwesomeIcon
        icon={faBookmark}
        size={"2xl"}
        style={{ color: iconStyle }}
      />
    </button>
  );
};

今回は、記事の詳細ページでコンポーネントを使用してみました。
少し長いので見にくいかもです...

import { useState, useEffect } from "react";
import { Link, useLocation } from "react-router-dom";
import { BookMarkButton } from "../components/BookMarkButton";
import { API_URL } from "../lib/client";
import { Loading } from "../components/Loading";
import axios from "axios";
import { addClip, deleteClip } from "../store/clipSlice";
import { useDispatch, useSelector } from "react-redux";

export const ArticleDetail = () => {
  const [filterArticle, setFilterArticle] = useState(null);
  const [loading, setLoading] = useState(true);

  const location = useLocation();
  const currentPath = location.pathname.substring(9);

  // クリップ記事の情報を取得
  const dispatch = useDispatch();
  const clips = useSelector((state) => state.clip.clips);
  const isEnable = clips.some((clip) => clip.publishedAt === currentPath);

   // 記事がクリップされている時とそうでない時に、呼ぶReducerを変える
  const ArticleClip = () => {
    if (isEnable) {
      dispatch(deleteClip(filterArticle));
    } else {
      dispatch(addClip(filterArticle));
    }
  };

    // 記事がクリップされている時とそうでない時とでアイコンの色を変える
  const bookMarkButtonStyle = isEnable ? "orange" : "black";

    // 記事の詳細情報を取得する処理
  useEffect(() => {
    const fetchNewsLists = async () => {
      try {
        const response = await axios.get(API_URL);
        const articles = response.data.articles;
        const filteredArticle = articles.find(
          (article: Article) => article.publishedAt === currentPath
        );
        if (filteredArticle) {
          setFilterArticle(filteredArticle);
          setLoading(false);
        } else {
          setLoading(false);
        }
      } catch (error) {
        console.error("Error fetching news:", error);
        setLoading(false);
      }
    };
    fetchNewsLists();
  }, [currentPath]);

  if (loading) {
    return <Loading />;
  }

  if (!filterArticle) {
    return <div>記事が見つかりませんでした。</div>;
  }

  return (
    <div className="container mx-auto py-8">
      <div className="max-w-2xl mx-auto bg-white rounded-lg shadow-lg p-8">
        <div className="mb-10">
          <BookMarkButton
            iconStyle={bookMarkButtonStyle}
            articleClip={ArticleClip}
          />
        </div>
        <h1 className="text-3xl font-semibold mb-4">{filterArticle.title}</h1>
        <p className="text-gray-500 mb-4">著者: {filterArticle.author}</p>
        <img
          src={filterArticle.urlToImage}
          alt={filterArticle.title}
          className="w-full rounded-lg mb-4"
        />
        <p className="text-gray-700 mt-10 mb-5">{filterArticle.description}</p>

        <span className="text-blue-500">
          <Link to={filterArticle.url}>続きはこちら→</Link>
        </span>
      </div>
    </div>
  );
};

コードを抜粋して、説明していきます。

DispatchはuseDispatchをインポートしてきます。
そして、引数にインポートしてきたsliceから使いたいreducerを指定します。
ここでは、開いた記事がクリップされていなければ、addClipをdispatchして記事をstoreに追加、クリップしていれば、deleteClipをdispatchして、記事をstoreから削除するReducerが通知されるようになっています。

import { useDispatch } from "react-redux";
import { addClip, deleteClip } from "../store/clipSlice";

const dispatch = useDispatch();

// 記事がクリップされている時とそうでない時に、呼ぶメソッドを変える
const ArticleClip = () => {
    if (isEnable) {
      dispatch(deleteClip(filterArticle));
    } else {
      dispatch(addClip(filterArticle));
    }
};

そして以下のコードで、ボタンのコンポーネントにメソッドを渡しています。

 <BookMarkButton
    iconStyle={bookMarkButtonStyle}
    articleClip={ArticleClip}
  />

次はクリップした記事を表示するコードはこちらです。

import { useState, useEffect } from "react";
import { faBookmark } from "@fortawesome/free-solid-svg-icons";

import { NewsList } from "../components/NewsList";
import { Loading } from "../components/Loading";
import { PageTitle } from "../components/PageTitle";
import { useSelector } from "react-redux";

export const Clip = () => {
  const [loading, setLoading] = useState(true);
  const clips = useSelector((state) => state.clip.clips);

  useEffect(() => {
    const fetchNewsLists = async () => {
      try {
        setLoading(false);
      } catch (error) {
        console.error("Error fetching news:", error);
        setLoading(false);
      }
    };
    fetchNewsLists();
  }, []);

  return (
    <div className="p-8">
      <PageTitle pageTitle="ストック記事" iconName={faBookmark} />
      {loading ? <Loading /> : <NewsList articles={clips} />}
    </div>
  );
};

useSelecter()をインポートして、store.jsに指定したclipを指定して、clipのデータを取得してきます。
そうすることで、Storeに登録したデータを取得することができます。
取得したデータは以下のコンポーネントに渡して表示しています。

import { Link } from "react-router-dom";

export const NewsList = ({ articles }) => {
  return (
    <div className="py-8">
      <div className="container mx-auto border-black">
        <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
          {articles.map((article, index) => (
            <div
              className="bg-white rounded-lg transition-transform transform shadow-md hover:opacity-75"
              key={index}
            >
              <div>
                <Link to={`/article/${article.publishedAt}`} className="flex">
                  <img
                    src={article.urlToImage}
                    alt="ニュース1"
                    className="w-60 h-60 object-cover rounded-l-lg"
                  />
                  <div className="px-2 pt-4 pb-2 pr-10">
                    <h2 className="text-xl font-semibold text-gray-800 mb-2">
                      {article.title}
                    </h2>
                    <p className="text-gray-600"></p>
                  </div>
                </Link>
              </div>
            </div>
          ))}
        </div>
      </div>
    </div>
  );
};

そうすることで、以下のように動作します。
タイトルなし.gif

Discussion