LoginSignup
4
2

More than 3 years have passed since last update.

redux, redux-sagaが少しだけ理解できたので,日報アプリを自作してみた

Posted at

本記事の目的

こんにちは!@flat-fieldです!
普段はアルバイトでバックエンドの実装を行なっておりますが,フロントエンドも少しは触れるようになりたいという思いから,react, redux, redux-sagaの勉強をしていました.
最初は「難しいな〜〜」と思うことばかりでしたが,3週間ほど勉強する中で少しずつ理解できることも増えたので,これらの技術を用いて日報アプリを作ってみました.

本記事では,redux, redux-sagaの仕組みについて,自作した日報アプリを元に説明できればと思います.
なお,細かい実装の部分には触れていません.なんとなく「redux, redux-sagaはこんな感じなんだなあ」と分かっていただければ幸いです(実装について詳しく理解したい方は,おすすめの書籍を最後に紹介しているので,そちらを参照してみてください).
なお勉強して間もないので,何か間違っていることや,補足事項がありましたら,コメントで教えていただけると有り難いです.

よろしくお願いいたします.

reduxとは?

Reactでは,コンポーネントを組み合わせてアプリケーションを構成します.
コンポーネントには「状態(state)を持つコンポーネント」と「状態(state)を持たないコンポーネント」があります.
実際のアプリケーションでは,複数のコンポーネント間で共有したいstateが存在することがよくあります.
例えば,ユーザのログイン情報を複数のコンポーネントで利用したいときなどが挙げられます.

では,あるstateを複数のコンポーネントで使用するにはどうすればいいでしょうか?

安直な方法としては,最上位コンポーネントに全てのstateを集約し,それを子コンポーネントにバケツリレーのごとくstateを引き継いであげる,ということが考えられます.でも,流石にそんなことしたくないですよね...

そこで,reduxが登場します.reduxは以下のような3つの原則を採用しており,シンプルかつ簡潔にあるstateを複数のコンポーネントで使用することができるようになります.

  • Single source of truth
  • State is read-only
  • Changes are made with pure functions

"Single source of truth"は,アプリケーションの全てのstateを"store"と呼ばれるオブジェクトで一元管理することを意味します.
複数のstoreで管理すると,store間のデータのやりとりが複雑になり,バグの温床となります.1つのstoreで管理することによって,テストやデバッグをやりやすくすることができます.

"State is read-only"は,stateを直接書き換えることができないことを意味します.もしstateを変更したい場合は,必ず"action"を発行する必要があります.この"action"を必ず発行しないとstateの状態を変更することができない仕組みによって,予期せぬ場所からstateの書き換えができないようにしています.

"Changes are made with pure functions"は,stateの変更は"Reducer"と呼ばれる純粋関数(引数が同じならば,必ず同じ結果が返される関数)によって行われます.つまりReducerは,stateとactionの2つを引数とし,新たなstateを返す,ということを実行します.

# 言うなればReducerはこんな感じ
(prevState, action) => newState

reduxの全体の概念図は以下の通りです.

スライド1.jpeg

図を見てもらうとわかるのですが,各ノードの依存関係が単方向ですね.これによりアプリケーションの構造が複雑になるのを防いでいます.

redux-sagaとは?

redux-sagaとは,一言で言うと「reduxで非同期処理を扱うための仕組み」のことです.
全体の概念図を見てみましょう.

スライド1.jpeg

ややこしくなったように見えますが,実は右側2/3はreduxと同じ構造になっています.reduxにsagaという,actionを監視して非同期処理を実現する機能を追加したものがredux-sagaです.

このように,特定のactionをWatch Taskが監視し,Taskが実際の非同期処理を実現することにより,シンプルな構造のまま非同期処理を実現することができます.

ちなみに余談ですが,reduxで非同期処理を実現するもう一つの手法として「redux-thunk」というものがあります.こちらはredux-sagaに比べて学習コストが小さくて済むというメリットがありますが,依存関係が複雑になり,react本来のコンポーネントの振る舞いができなくなるというデメリットがあります.

日報アプリを作ってみた!

redux, redux-sagaについて説明したところで,実際に日報アプリを作ってみました.
この日報アプリでは,以下のことができます.

- その日の振り返り,明日の目標を毎日記録することができる
- 当日の日報のみ修正することができる.
- 過去の日報を閲覧することができる.

Githubはこちらから参照してください.
(なお,APIについても自作しており,こちらから参照できます.)

ちなみにこんな感じの動作です⬇️.
ezgif-5-1e8a54e3bf09.gif

redux-sagaを用いた開発の流れ

全体の概念図の流れ通りに実装すると良さそうです.なので以下のような流れで実装しました.
(1) まずは非stateコンポーネント(Viewの役割をメインに担う)を作成.
(2) Actionを実装する.
(3) Reducerを実装する.
(4) 非同期処理通信を実装する.
(5) (4)の非同期処理を利用するSagaのタスクを作成する.
(6) 最上位のコンポーネントに,全コンポーネントでsagaを利用できるような仕組みを実装する.
(7) 最後に,state有コンポーネント((1)をimportして,機能を追加するコンポーネント)を作成する.

(1) まずは非stateコンポーネント(Viewの役割をメインに担う)を作成.

序盤でもお伝えしましたが,コンポーネントには2種類有ります.ここでは,非stateコンポーネントを先に実装し,Viewを整えます.

src/components/*.tsxがこれに該当します.

一例として,日報を記入するためのコンポーネントsrc/components/WhiteDailyPart.tsxをみてみましょう.

src/component/WhiteDailyPart.tsx
import React, { FC } from 'react';
import { Grid, Form, Button } from 'semantic-ui-react';

export interface WriteDailyProps {
  postDailyTmp?: () => void;
}

const WriteDailyPart: FC<WriteDailyProps> = ({
  postDailyTmp = () => undefined,
}) => (
  <Grid.Column width={10}>
    <Form>
      <Form.Field>
        <Form.TextArea
          id="today"
          label="今日の振り返り"
          rows={20}
          placeholder="ここに本日の振り返りを記入してください.フォーマットは規定のものにしたがってください.そして毎日継続しまししょう."
        />
      </Form.Field>

      <Form.Field>
        <Form.TextArea
          id="tomorrow"
          label="明日の目標"
          rows={10}
          placeholder="明日の目標を具体的に記述しましょう."
        />
      </Form.Field>

      <Form.Field>
        <Form.Input
          fluid
          id="point"
          label="本日の点数(100点満点中)"
          placeholder="本日の点数(100点満点中)"
        />
      </Form.Field>

      <Button type="submit" onClick={postDailyTmp}>
        提出する
      </Button>
    </Form>
  </Grid.Column>
);

export default WriteDailyPart;

(2) Actionを実装する.

続いてActionを実装します.src/actions/daily.tsをみてみましょう.

流れとしては,

  • Action Type(それぞれのActionに付与するもの)を定義
  • Actionを発行するための関数を実装
  • Return Typeをまとめて型定義

という感じです.

src/actions/daily.ts
import { AxiosError } from 'axios';
import { Daily } from '../services/daily/model';

// 1. Action Typeを定義
export const REGISTER_DAILY_START = 'DAILY/REGISTER_DAILY_START';
export const REGISTER_DAILY_SUCCEED = 'DAILY/REGISTER_DAILY_SUCCEED';
export const REGISTER_DAILY_FAIL = 'DAILY/REGISTER_DAILY_FAIL';

export const GET_DAILIES_START = 'DAILY/GET_DAILIES_START';
export const GET_DAILIES_SUCCEED = 'DAILY/GET_DAILIES_SUCCEED';
export const GET_DAILIES_FAIL = 'DAILY/GET_DAILIES_FAIL';

// 2. Actionの発行
export const registerDaily = {
  start: (params: Daily) => ({
    type: REGISTER_DAILY_START as typeof REGISTER_DAILY_START,
    payload: params,
  }),

  succeed: (params: Daily, result: Daily) => ({
    type: REGISTER_DAILY_SUCCEED as typeof REGISTER_DAILY_SUCCEED,
    payload: { params, result },
  }),

  fail: (params: Daily, error: AxiosError) => ({
    type: REGISTER_DAILY_FAIL as typeof REGISTER_DAILY_FAIL,
    payload: { params, error },
    error: true,
  }),
};

export const getDailies = {
  start: () => ({
    type: GET_DAILIES_START as typeof GET_DAILIES_START,
  }),

  succeed: (result: Daily[]) => ({
    type: GET_DAILIES_SUCCEED as typeof GET_DAILIES_SUCCEED,
    payload: { result },
  }),

  fail: (error: AxiosError) => ({
    type: GET_DAILIES_FAIL as typeof GET_DAILIES_FAIL,
    payload: { error },
  }),
};

// 3. ReturnTypeをまとめて型定義.あとで使う.
export type DailyAction =
  | ReturnType<typeof registerDaily.start>
  | ReturnType<typeof registerDaily.succeed>
  | ReturnType<typeof registerDaily.fail>;

export type GetDailiesAction =
  | ReturnType<typeof getDailies.start>
  | ReturnType<typeof getDailies.succeed>
  | ReturnType<typeof getDailies.fail>;

(3) Reducerを実装する.

続いてReducerを実装します.前述の通り,Reducerはactionを受け取って,それぞれのactionごとに(prevState, action) => newStateを実行します.

src/reducer.ts
import { Reducer } from 'redux';

import { Daily } from './services/daily/model';
import {
  DailyAction,
  GetDailiesAction,
  REGISTER_DAILY_START,
  REGISTER_DAILY_SUCCEED,
  REGISTER_DAILY_FAIL,
  GET_DAILIES_START,
  GET_DAILIES_FAIL,
  GET_DAILIES_SUCCEED,
} from './actions/daily';

export interface DailyState {
  dailies: Daily[];
}

export const initialState: DailyState = { dailies: [] };

const counterReducer: Reducer<DailyState, DailyAction | GetDailiesAction> = (
  state: DailyState = initialState,
  action: DailyAction | GetDailiesAction,
): DailyState => {
  switch (action.type) {
    case REGISTER_DAILY_START:
      return {
        ...state,
        dailies: state.dailies,
      };
    case REGISTER_DAILY_SUCCEED:
      return {
        ...state,
        dailies: [action.payload.result, ...state.dailies],
      };
    case REGISTER_DAILY_FAIL:
      return {
        ...state,
      };
    case GET_DAILIES_START:
      return {
        ...state,
        dailies: state.dailies,
      };
    case GET_DAILIES_SUCCEED:
      return {
        ...state,
        dailies: [...state.dailies, ...action.payload.result],
      };
    case GET_DAILIES_FAIL:
      return {
        ...state,
      };
    default: {
      // eslint-disable-next-line @typescript-eslint/no-unused-vars
      // const _: never = action;

      return state;
    }
  }
};

export default counterReducer;

(4) 非同期処理通信を実装する.

続いて非同期処理通信を実装します.
src/services/daily/api.tsでは,APIを叩くためのインタフェース(postDailyFactory, getDailiesFactory)を提供しています.

src/services/daily/api.ts
import axios from 'axios';

import { Daily } from './model';

interface ApiConfig {
  baseURL: string;
  timeout: number;
}

const DEFAULT_API_CONFIG: ApiConfig = {
  baseURL: 'http://localhost:8080',
  timeout: 7000,
};

// クロージャーの利用
// 実際の使い方:const getMembers = getMembersFactory({timeout: 3000});
//              try {
//                const users = await getMembers(‘facebook’);
//              } catch(err) {
//                console.log(err);
//              }

export const postDailyFactory = (optionConfig?: ApiConfig) => {
  const config = {
    ...DEFAULT_API_CONFIG,
    ...optionConfig,
  };
  const instance = axios.create(config);

  const postDaily = async (daily: Daily) => {
    const params = new URLSearchParams();
    params.append('id', `${daily.id}`);
    params.append('date', daily.date);
    params.append('today', daily.today);
    params.append('tomorrow', daily.tomorrow);
    params.append('point', daily.point);
    const response = await instance.post(`/daily/create`, params);

    if (response.status !== 200) {
      throw new Error('Server Error');
    }
    const responseDaily: Daily = {
      id: response.data.daily.ID,
      date: response.data.daily.date,
      today: response.data.daily.today,
      tomorrow: response.data.daily.tomorrow,
      point: response.data.daily.point,
    };

    return responseDaily;
  };

  return postDaily;
};

export const getDailiesFactory = (optionConfig?: ApiConfig) => {
  const config = {
    ...DEFAULT_API_CONFIG,
    ...optionConfig,
  };
  const instance = axios.create(config);
  const dailies: Daily[] = [];

  const getDailies = async () => {
    const response = await instance.get(`/dailies`);

    if (response.status !== 200) {
      throw new Error('Server Error');
    }
    // console.log(response.data.dailies);
    for (let i = 0; i < response.data.dailies.length; i += 1) {
      const d: Daily = {
        id: response.data.dailies[i].ID,
        date: response.data.dailies[i].date,
        today: response.data.dailies[i].today,
        tomorrow: response.data.dailies[i].tomorrow,
        point: response.data.dailies[i].point,
      };
      dailies.push(d);
    }

    // return JSON.stringify(response.data.dailies);
    return dailies;
  };

  return getDailies;
};

(5) (4)の非同期処理を利用するSagaのタスクを作成する.

続いて,sagaのタスクを作成します(src/sagas/).この部分では,特定のactionを観測したら,(4)で作成したAPIインタフェースを利用してAPIを叩き,正しく結果が返ってきたらsucceedアクションを発行し,stateを変更します.もしエラーが返ってきたらfailアクションを発行します.

daily.ts
import { all, call, fork, put, takeLatest } from 'redux-saga/effects';

// import * as Action from '../actions/daily';
import {
  registerDaily,
  getDailies,
  REGISTER_DAILY_START,
  GET_DAILIES_START,
} from '../actions/daily';
import { postDailyFactory, getDailiesFactory } from '../services/daily/api';

function* runPostDaily(action: ReturnType<typeof registerDaily.start>) {
  const daily = action.payload;
  try {
    const api = postDailyFactory();
    const resultDaily = yield call(api, daily);

    yield put(registerDaily.succeed(daily, resultDaily));
  } catch (error) {
    yield put(registerDaily.fail(daily, error));
  }
}

function* runGetDailies(action: ReturnType<typeof getDailies.start>) {
  try {
    const api = getDailiesFactory();
    const resultDailies = yield call(api);

    yield put(getDailies.succeed(resultDailies));
  } catch (error) {
    yield put(getDailies.fail(error));
  }
}

// take … 特定のActionを待ち受ける.
// takeEvery … 渡されたActionの数だけ律儀にタスクを実行する
// takeLatest … 最新のものだけを実行する
export function* watchPostDaily() {
  yield takeLatest(REGISTER_DAILY_START, runPostDaily);
}

export function* watchGetDailies() {
  yield takeLatest(GET_DAILIES_START, runGetDailies);
}

// これをSagaミドルウェアに渡すとアプリ起動時に同時に起動されて,ここでforkされた分だけ別のタスクも立ち上がってスタンバイする.
export default function* rootSaga() {
  yield all([fork(watchPostDaily), fork(watchGetDailies)]);
}

(6) 最上位のコンポーネントに,全コンポーネントでsagaを利用できるような仕組みを実装する.

最上位コンポーネントである,src/index.tsxにsagaを利用できる仕組みを実装します.

src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { createStore, applyMiddleware } from 'redux';
import { BrowserRouter } from 'react-router-dom';
import createSagaMiddleware from 'redux-saga';

import App from './App';
import dailyReducer from './reducer';
import rootSaga from './sagas/daily';
import * as serviceWorker from './serviceWorker';

import './index.css';
import './styles/semantic.min.css';

// 以下2行を追加
const sagaMiddleWare = createSagaMiddleware();
const store = createStore(dailyReducer, applyMiddleware(sagaMiddleWare));

ReactDOM.render(
  <Provider store={store}>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </Provider>,
  document.getElementById('root') as HTMLInputElement,
);

serviceWorker.unregister();

// これも追加
sagaMiddleWare.run(rootSaga);

(7) 最後に,state有コンポーネント((1)をimportして,機能を追加するコンポーネント)を作成する.

最後に,state有コンポーネント(通称コンテナ)を作成して,storeとコンポーネントのpropsを結びつける作業をします.
src/containers/*.tsxが該当のコンテナです.ここではsrc/containers/WriteDailyPart.tsxをみていきましょう.

src/containers/WriteDailyPart.tsx
import React, { FC } from 'react';
import { connect } from 'react-redux';
import { bindActionCreators, Dispatch } from 'redux';
import { RouteComponentProps, withRouter } from 'react-router';

import WriteDailyPart, { WriteDailyProps } from '../components/WriteDailyPart';
import { Daily } from '../services/daily/model';
import { DailyState } from '../reducer';
import { registerDaily } from '../actions/daily';

interface StateProps {
  dailies: Daily[];
}

interface DispatchProps {
  postDaily: (daily: Daily) => void;
}

type EnhancedMembersProps = WriteDailyProps &
  StateProps &
  DispatchProps &
  RouteComponentProps;

const mapStateToProps = (state: DailyState): StateProps => ({
  dailies: state.dailies,
});

const mapDispatchToProps = (dispatch: Dispatch): DispatchProps =>
  bindActionCreators(
    {
      postDaily: (daily: Daily) => registerDaily.start(daily),
    },
    dispatch,
  );

const DailyContainer: FC<EnhancedMembersProps> = ({ dailies, postDaily }) => {
  const postDailyTmp = () => {
    const today = document.getElementById('today') as HTMLInputElement;
    const tomorrow = document.getElementById('tomorrow') as HTMLInputElement;
    const point = document.getElementById('point') as HTMLInputElement;
    const todayDate = new Date();
    const date =
      `${todayDate.getFullYear()}` +
      `${('0' + (todayDate.getMonth() + 1)).slice(-2)}` +
      `${todayDate.getDate()}`;
    const daily: Daily = {
      id: 3,
      date,
      today: today.value,
      tomorrow: tomorrow.value,
      point: point.value,
    };
    postDaily(daily);
  };

  return <WriteDailyPart postDailyTmp={postDailyTmp} />;
};

// DailyContainerの(state, dispatch)と,storeやactionで管理している(state, dispatch)を結びつける.
export default withRouter(
  connect(mapStateToProps, mapDispatchToProps)(DailyContainer),
);

これで完成です.

最後に

redux-sagaは概念自体はそこまで難しくないのですが,実装するのはかなり難しかったです.
私はこの本で学習することにより,体系的にreact, redux, redux-sagaを学ぶことができました.
おすすめなので,この記事でreact, redux, redux-sagaに興味を持った方は,一度ご購入を検討してみてください!

4
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
2