Redux Sagaを利用する場合にどのような設定やコードを記述すれば動作させることができるかシンプルなコードを利用して確認していきます。

他の人のコードを読んでいたらRedux Sageが利用されていたけどRedux Sageがわからないから理解できないといった時などにも役立てもらえればと思います。

Reduxの基本的な理解があるものとして説明を行なっているのでReduxについて不安がある人は下記の公開済みの記事を参考にしてください。

プロジェクトの作成

npx create-react-appコマンドを利用してredux-saga-learnプロジェクトを作成します。


 % npx create-react-app redux-saga-learn

プロジェクトの作成後、作成されるフォルダに移動してreduxライブラリのインストールを行います。redux-sagaも合わせてインストールします。


 % npm install redux react-redux redux-saga

ライブラリのインストール後package.jsonファイルの中身を確認して今回動作確認を行なったライブラリのバージョンを確認しておきます。


{
  "name": "redux-sage-learn-2",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "@testing-library/jest-dom": "^5.14.1",
    "@testing-library/react": "^13.0.0",
    "@testing-library/user-event": "^13.2.1",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-redux": "^8.0.4",
    "react-scripts": "5.0.1",
    "redux": "^4.2.0",
    "redux-saga": "^1.2.1",
    "web-vitals": "^2.1.0"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  },
//略
}

初期設定

プロジェクトの作成が完了したらApp.jsファイルを以下のように更新します。Redux, Redux Sageを利用せず非同期で記事一覧を取得するためのコードを記述します。

importするPostコンポーネントはこの後作成します。


import './App.css';
import Post from './components/Post';

function App() {
  return (
    <div className="App">
      <h1>Redux Sage Learn</h1>
      <Post />
    </div>
  );
}

export default App;

srcフォルダにcomponentsフォルダを作成し、Post.jsファイルを作成します。


import { useState, useEffect } from 'react';

const Post = () => {
  const [posts, setPosts] = useState([]);

  useEffect(() => {
    const getPosts = async () => {
      const res = await fetch('https://jsonplaceholder.typicode.com/posts');
      const data = await res.json();
      setPosts(data);
    };
    getPosts();
  }, []);

  return (
    <div>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
};

export default Post;

Postコンポーネントでは無料で利用できるJSONPlaceHolderにアクセスを行い、記事一覧をfetch関数で取得し、ブラウザ上に表示するというだけのシンプルなものです。useState Hookを利用してPostコンポーネントのみで利用できる状態postsを定義してJSONPlaceHolderから取得した記事一覧を保存しmap関数で展開しブラウザ上に表示せています。

アクセスするJSONPlaceHolderのURLはhttps://jsonplaceholder.typicode.com/postsです。ブラウザでアクセスしても100件の記事のダミーデータを取得することができます。

npm startコマンドを実行して開発サーバを起動するとブラウザ上にはJSONPlaceHolderから取得した記事一覧が表示されます。

記事一覧を表示
記事一覧を表示

Redux Sageを利用してコードを書き換えてきます。

Reduxの設定

srcフォルダにstoreフォルダを作成してindex.jsファイルを作成します。reducerのinitialStateとして3つの変数posts, loading, errorを定義しています。postsには記事一覧を保存します。loadingはBoolean値のtrue, falseを設定することができます。記事一覧を取得中にLoadingを画面上に表示するために利用します。errorにはデータ取得に失敗した場合のメッセージを保存します。


import { createStore } from 'redux';

const initialState = {
  posts: [],
  loading: false,
  error: null,
};

const reducer = (state = initialState, action) => {
  switch (action.type) {
    case 'GET_POSTS_REQUESTED':
      return { ...state, loading: true };

    case 'GET_POSTS_SUCCESS':
      return { ...state, loading: false, posts: action.posts };

    case 'GET_POSTS_FAILED':
      return { ...state, loading: false, error: action.message };

    default:
      return state;
  }
};

const store = createStore(reducer);

export default store;

reducerではswitch関数を利用して3つのAction Typeを設定しています。GET_POSTS_REQUESTEDはデータ取得のリクエストを行う際にdispatchされloadingの値をfalseからtrueにしています。GET_POSTS_SUCCESSはデータ取得に成功した場合にdispatchされactionのpayloadに含まれるposts一覧のデータをpostsに保存しています。GET_POST_FAILEDはデータ取得に失敗した時にdispatchされerrorにaction payloadに含まれるmessageを保存しています。

ここまでの設定はRedux Sageに関わらずReduxを利用する際に行う設定と変わりません。

Redux Sageの設定

ここからRedux Sageの設定を行うためにsrcフォルダにsagasフォルダを作成し、postSage.jsファイルを作成します。


import { call, put, takeLatest } from 'redux-saga/effects';

const fetchGetPosts = async () => {
  try {
    const res = await fetch('https://jsonplaceholder.typicode.com/posts');
    if (!res.ok) {
      throw new Error(`${res.status} ${res.statusText}`);
    }
    return res.json();
  } catch (error) {
    throw error;
  }
};

function* fetchPosts() {
  try {
    const posts = yield call(fetchGetPosts);
    yield put({ type: 'GET_POSTS_SUCCESS', posts: posts });
  } catch (e) {
    yield put({ type: 'GET_POSTS_FAILED', message: e.message });
  }
}

function* postSaga() {
  yield takeLatest('GET_POSTS_REQUESTED', fetchPosts);
}

export default postSaga;

コードにはfunction*やyield, call, put, takeLatestなど見慣れない文字列が含まれていますがRedux Sagaで利用する文字列なので慣れてしまえば特別難しいものはないので安心してください。

最初にfetchGetPosts関数を確認します。これはただの非同期の関数です。データの取得に成功すればres.json()を戻し、失敗した場合にはエラーをthrowさせます。fetch関数では400, 500などのステータスコードが戻されたとしてもそのまま処理を行うためres.okで確認を行うことでエラーを検知させます。ステータスコードが200の場合はres.okはtrueになりますが400や500ではres.okはfalseとなります。

function*がついたpostSaga関数では先ほどのRedux設定で追加したAction Typeの”GET_POSTS_REQUESTED”がdispatchされるかをyield takeLatestで監視しています。GET_POSTS_REQUESTEDがdispatchされるとfetchPosts関数が実行されることになります。


function* postSaga() {
  yield takeLatest('GET_POSTS_REQUESTED', fetchPosts);
}

takeLatestの引数に設定されたfetchPostsにも関数名の前にfunction*をつけます。

fetchPost関数内のyield callでは非同期の関数(fetchGetPosts)を指定することができ、非同期の処理が完了するまで次の処理を行いません。

処理に成功、失敗に関わらずyield putにActionを設定することでdispatchを行うことができます。処理に成功した場合はAction Typeの”GET_POSTS_SUCCESS”を設定し、postsの値はfetchGetPostsで取得済みなのでActionのpayloadに取得した値を指定しています。取得に失敗した場合はAction Typeの”GET_POSTS_FAILED”を設定し、messageの値はfetchGetPosts関数が失敗した場合にthrowされるエラーを設定します。


function* fetchPosts() {
  try {
    const posts = yield call(fetchGetPosts);
    yield put({ type: 'GET_POSTS_SUCCESS', posts: posts });
  } catch (e) {
    yield put({ type: 'GET_POSTS_FAILED', message: e.message });
  }
}

Redux Sageの設定が完了したらPostコンポーネントで”GET_POSTS_REQUESTED”をdispatchします。GET_POSTS_REQUESTEDはtakeLatestで監視されているのでGET_POSTS_REQUESTEDを検知するとfetchPosts関数を実行します。fetchPosts関数ではcallで非同期関数のfetchGetPostsを実行し、成功した場合にはputで”GET_POSTS_SUCCESS”をdispatchし、失敗した場合には”GET_POSTS_FAILED”をdispatchします。yield, call, put, takeLatestの見慣れない関数があるせいか最初は複雑そうに見えました一つ一つ流れを追うことで特別難しい設定ではないことが理解できます。

これだけでは設定したRedux Sagaを利用できないのでReduxにmiddelwareとして設定を行う必要があります。


import { createStore, applyMiddleware } from 'redux';
import createSagaMiddleware from 'redux-saga';

import postSaga from '../sagas/postSaga.js';

const sagaMiddleware = createSagaMiddleware();

const initialState = {
  posts: [],
  loading: false,
  error: null,
};

const reducer = (state = initialState, action) => {
  switch (action.type) {
    case 'GET_POSTS_REQUESTED':
      return { ...state, loading: true };

    case 'GET_POSTS_SUCCESS':
      return { ...state, loading: false, posts: action.posts };

    case 'GET_POSTS_FAILED':
      return { ...state, loading: false, error: action.message };

    default:
      return state;
  }
};

const store = createStore(reducer, applyMiddleware(sagaMiddleware));

sagaMiddleware.run(postSaga);

export default store;

Reduxを利用するためにsrcフォルダのindex.jsでAppコンポーネントをProviderコンポーネントでラップする必要があります。これはRedux Sagaに限らずReduxを利用する場合に行う設定です。


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

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <React.StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </React.StrictMode>
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

Post.jsファイルでは”GET_POSTS_REQUESTED”をdispatchするようにコードを更新します。useEffect内でdispatchによりGET_POST_REQUESTEDをdispatchするとRedux Sageで監視していたfetchPostが実行されます。


import { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';

const Post = () => {
  const dispatch = useDispatch();
  const posts = useSelector((state) => state.posts);
  const loading = useSelector((state) => state.loading);
  const error = useSelector((state) => state.error);

  useEffect(() => {
    dispatch({
      type: 'GET_POSTS_REQUESTED',
    });
  }, [dispatch]);

  return (
    <>
      {loading && <p>Loading...</p>}
      {error && !loading && <p>{error}</p>}
      {posts && (
        <ul>
          {posts.map((post) => (
            <li key={post.id}>{post.title}</li>
          ))}
        </ul>
      )}
    </>
  );
};

export default Post;

これで設定は完了です。

Redux Sagaを設定する前と同様に記事一覧がブラウザ上に表示されます。

記事一覧を表示
記事一覧を表示

loadingを設定しているのでネットワークが遅い場合には画面上には”Loading…”の文字が表示されます。

URLを存在しないものに変更すると404のエラーメッセージが表示されます。

404エラーメッセージ
404エラーメッセージ

複数のSagaの設定

‘GET_POSTS_REQUESTED’を監視するpostSagaのみ設定を行いましたがアクセスするURLと取得する内容が異なるuserSage.jsを作成して複数のSagaが存在する場合の設定方法を確認します。

Postで行なった処理とコードはほとんど同じです。通常は全く異なるコードになるはずです。

componentsフォルダにUser.jsファイルを作成して以下のコードを記述します。


import { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';

const User = () => {
  const dispatch = useDispatch();
  const users = useSelector((state) => state.users);
  const loading = useSelector((state) => state.loading);
  const error = useSelector((state) => state.error);

  useEffect(() => {
    dispatch({
      type: 'GET_USERS_REQUESTED',
    });
  }, [dispatch]);

  return (
    <>
      {loading && <p>Loading...</p>}
      {error && !loading && <p>{error}</p>}
      {users && (
        <ul>
          {users.map((user) => (
            <li key={user.id}>{user.name}</li>
          ))}
        </ul>
      )}
    </>
  );
};

export default User;

sagasフォルダにuserSage.jsファイルを作成して以下を記述します。


import { call, put, takeLatest } from 'redux-saga/effects';

const fetchGetUsers = async () => {
  try {
    const res = await fetch('https://jsonplaceholder.typicode.com/users');
    if (!res.ok) {
      throw new Error(`${res.status} ${res.statusText}`);
    }
    return res.json();
  } catch (error) {
    throw error;
  }
};

function* fetchUsers() {
  try {
    const users = yield call(fetchGetUsers);
    yield put({ type: 'GET_USERS_SUCCESS', users: users });
  } catch (e) {
    yield put({ type: 'GET_USERS_FAILED', message: e.message });
  }
}

function* userSaga() {
  yield takeLatest('GET_USERS_REQUESTED', fetchUsers);
}

export default userSaga;

App.jsファイルに作成したUser.jsファイルをimportします。


import './App.css';
import Post from './components/Post';
import User from './components/User';

function App() {
  return (
    <div className="App">
      <h1>Redux Sage Learn</h1>
      <Post />
      <User />
    </div>
  );
}

export default App;

ブラウザで確認すると記事一覧は表示されますがユーザ一覧は表示されません。store/index.jsファイルでAction Typeの追加と複数のsagaを設定するためにrootSage関数を追加します。設定はyield allを使った設定したSagaを配列で指定します。配列なのでいくつもSagaを追加することができます。


import { createStore, applyMiddleware } from 'redux';
import createSagaMiddleware from 'redux-saga';
import { all } from 'redux-saga/effects';

import postSaga from '../sagas/postSaga.js';
import userSaga from '../sagas/userSaga.js';

const sagaMiddleware = createSagaMiddleware();

const initialState = {
  posts: [],
  users: [],
  loading: false,
  error: null,
};

const reducer = (state = initialState, action) => {
  switch (action.type) {
    case 'GET_POSTS_REQUESTED':
      return { ...state, loading: true };

    case 'GET_POSTS_SUCCESS':
      return { ...state, loading: false, posts: action.posts };

    case 'GET_POSTS_FAILED':
      return { ...state, loading: false, error: action.message };

    case 'GET_USERS_REQUESTED':
      return { ...state, loading: true };

    case 'GET_USERS_SUCCESS':
      return { ...state, loading: false, users: action.users };

    case 'GET_USERS_FAILED':
      return { ...state, loading: false, error: action.message };

    default:
      return state;
  }
};

const store = createStore(reducer, applyMiddleware(sagaMiddleware));

function* rootSaga() {
  yield all([postSaga(), userSaga()]);
}

sagaMiddleware.run(rootSaga);

export default store;

ブラウザで確認すると記事一覧とユーザ一覧が表示されます。

Redux toolkitを利用した場合

Redux toolkitを利用するためにはライブラリのインストールが必要になります。


 % npm install @reduxjs/toolkit

storeフォルダにpostSlice.jsファイルを作成して以下を記述します。


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

export const postSlice = createSlice({
  name: 'post',
  initialState: {
    posts: [],
    loading: false,
    error: null,
  },
  reducers: {
    getPostRequested: (state) => {
      state.loading = true;
    },
    getPostSuccess: (state, action) => {
      state.loading = false;
      state.posts = action.payload.posts;
    },
    getPostFailed: (state, action) => {
      state.loading = false;
      console.log(action.payload.message);
      state.error = action.payload.message;
    },
  },
});

export const { getPostRequested, getPostSuccess, getPostFailed } =
  postSlice.actions;

export default postSlice.reducer;

postSaga.jsファイルも作成したpostSlice.jsからimportしたAction Creatorsを使って更新します。


import { call, put, takeLatest } from 'redux-saga/effects';
import {
  getPostFailed,
  getPostRequested,
  getPostSuccess,
} from '../store/postSlice';

const fetchGetPosts = async () => {
  try {
    const res = await fetch('https://jsonplaceholder.typicode.com/poss');
    if (!res.ok) {
      throw new Error(`${res.status} ${res.statusText}`);
    }
    return res.json();
  } catch (error) {
    throw error;
  }
};

function* fetchPosts() {
  try {
    const posts = yield call(fetchGetPosts);
    yield put(
      getPostSuccess({
        posts: posts,
      })
    );
  } catch (e) {
    yield put(getPostFailed({ message: e.message }));
  }
}

function* postSaga() {
  yield takeLatest(getPostRequested().type, fetchPosts);
}

export default postSaga;

store/index.jsファイルではconfigureStore関数を利用してstoreインスタンスを作成します。


import { configureStore } from '@reduxjs/toolkit';
import postRecuder from './postSlice';
import createSagaMiddleware from 'redux-saga';
import postSaga from '../sagas/postSaga.js';

const sagaMiddleware = createSagaMiddleware();
const middleware = [sagaMiddleware];

export const store = configureStore({
  reducer: {
    post: postRecuder,
  },
  middleware,
});

sagaMiddleware.run(postSaga);

export default store;

Post.jsファイルではdispatchの引数にpostSlice.jsからimportしたAction creatorsを設定します。


import { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { getPostRequested } from '../store/postSlice';

const Post = () => {
  const dispatch = useDispatch();
  const posts = useSelector((state) => state.post.posts);
  const loading = useSelector((state) => state.post.loading);
  const error = useSelector((state) => state.post.error);

  useEffect(() => {
    dispatch(getPostRequested());
  }, [dispatch]);

  return (
    <>
      {loading && <p>Loading...</p>}
      {error && !loading && <p>{error}</p>}
      {posts && (
        <ul>
          {posts.map((post) => (
            <li key={post.id}>{post.title}</li>
          ))}
        </ul>
      )}
    </>
  );
};

export default Post;

ここまでの設定を行うとRedux toolkitを利用する前と同様に記事一覧が表示されます。