React+Laravelで習慣管理アプリを作る(その2・React編)

前回は開発環境の構築までを紹介したので、今回はフロントであるReactの実装について紹介する。

↑前回の記事

アプリ全体の流れ

基本的な流れはViewからアクションが発行されるとReducerに通知され、Stateが更新される。
非同期リクエストはViewとReducer間のMiddleware機能を使い、ReduxSagaを発行させ、Axiosを利用してLaravelのAPIを叩く。
返ってきたレスポンスをReduxSagaからアクションを発行することでReducerに通知する。

ディレクトリ構成

ページとなるJSXはpageに、それらを構成するコンポーネントはcomponentsに分けて配置した。
また、Reduxに関連するファイルはreduxにまとめた。
これらはトップダウン、ボトムアップともにソースを辿りやすくするための配置である。

状態管理

ReactReduxを利用し、アプリ全体で利用する状態はreducerに、コンポーネント単位でしか利用しない状態はReactのstateを利用した。
表示しているページに関係ないデータをreducerで抱えるのを避けるためだ。

モジュール

Reduxを使うとAction、Reducer、ActionCreatorとファイルが増えていくので、下記のように1ファイルにまとめる形をとった。
ReduxSagaまでまとめるとファイルの行数が増えて帰って見づらくなるので、ReduxSagaは別ファイルとした。

import actionCreatorFactory from 'typescript-fsa';
import { reducerWithInitialState } from 'typescript-fsa-reducers';

import Habits from '../../models/Habits';

// Action Creator
const actionCreator = actionCreatorFactory('Habit');
export const HabitsActions = {
  getHabits: actionCreator<void>('getHabits'),
  setHabits: actionCreator<Habits>('setHabits'),
  addHabit: actionCreator<object>('addHabit'),
  updateHabit: actionCreator<{ habitId: number; values: object }>(
    'updateHabit',
  ),
  removeHabit: actionCreator<number>('removeHabit'),
  formInitialize: actionCreator<number>('habitFormInitialize'),
};

// Reducers
export const habitsReducer = reducerWithInitialState(new Habits()).case(
  HabitsActions.setHabits,
  (state, payload) => {
    return state.set('items', payload.getList());
  },
);

例えば、習慣に関するモジュールを記述したのが上のコードである。
typescript-fsa-reducerを利用したので、1ファイルですっきり見通せるようになった。

モデル

例えば習慣を表現するモデルが下記。

import { List, Record } from 'immutable';
import { Dayjs } from 'dayjs';

import { JSObject } from '../types/Common';
import dayjs from '../lib/dayjs-ja';

import HabitRecords from './HabitRecords';

export class Habit extends Record<{
  id: number;
  habitName: string;
  repeatType: string;
  repeatValue: number;
  startedAt: Dayjs;
  targetTime: number; // 単位は「分」
  timeOfDay: string;
  consecutiveDays: number;
  //consecutiveWeeks: number | null;
}>({
  id: 0,
  habitName: '',
  repeatType: 'day_of_week',
  repeatValue: 127,
  startedAt: dayjs(),
  targetTime: 0,
  timeOfDay: '',
  consecutiveDays: 0,
  //consecutiveWeeks: null,
}) {
  static fromResponse(response: JSObject): Habit {
    const params = { ...response };
    return new Habit(params);
  }
  isCompleted(date: Dayjs, habitRecords: HabitRecords): boolean {
    return habitRecords
      ? habitRecords
          .filterById(this.id)
          .filterByCompletedAt(date)
          .getList().size > 0
      : false;
  }
}

export default class Habits extends Record<{
  items: List<Habit>;
}>({
  items: List(),
}) {
  static fromResponse(response: JSObject): Habits {
    const params = { ...response };
    params.items = List(
      params.habits.map((item: JSObject) => Habit.fromResponse(item)),
    );
    return new Habits(params);
  }

  getById(id: number): Habit | undefined {
    return this.get('items').find(value => value.id === id);
  }

  getList(): List<Habit> {
    return this.get('items');
  }
}

非同期リクエストで受け取るレスポンスはLaravel側のモデルと同じ単位で扱うことになるので、React側でもImmutableJsを用いてモデルを作成した。
モデル内にフィルタリングなどの処理を含められるので、Reactのrender()内でごちゃごちゃ弄る必要がなくなる。
(途中からこのルールを逸脱して見づらいクソコードになってしまったのは反省すべきところ。)

認証ルーティング

Laravel側ではReactを表示するindex.htmlのみを返し、ルーティングはreact-routerで行った。
ログイン状態に応じてページを遷移する、というのをReact側で実装したかったため。
ReactRouterのSwitchが返されるコンポーネントのcomponentDidMount()でログインチェックを行い、結果に応じてルーティングを行った。

非同期リクエスト

ReduxSagaをMiddlewareに登録して、Axiosを利用してLaravelにリクエストを送る形を取った。
AxiosはデフォルトでCookie周りを処理してくれるので認証に関して特に設定した点はなかった。

フォーム

基本的にフォーム入力を行う箇所はredux-formを利用した。
reducerをフォーム用に使うことなく簡単にフォームを構築できるためである。
未実装だが、エラー表示などもフィールドに与えられたプロパティから簡単に実装できるので、フォームを作る場合は有用なライブラリだ。

テスト

テストにはJestを用いた。
Viewに関してはスナップショットを取って、意図しない変更が起きないようにした。 sそれに加えてハンドラが機能しているか、stateによるフィルタリングが適切に機能しているかなどをコンポーネントやページ毎にテストを行った。

次回

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です

CAPTCHA