🔖

【中~大規模チーム向け】React+Reduxの設計に困ったあなたへ送る設計案

2022/05/16に公開約14,600字

今現在、React、Reduxを使っているプロジェクトは日本国内においてもたくさんあるかなって思います。ディレクトリファイル構成などの設計に関しても、各社・各人様座あり、答えもないので、悩ましいものでもあります。

ただ、チーム開発では、ここをしっかりルールづけしておかないと、個人任せの無秩序なソースコードが散乱し、途中で参画したメンバーはキャッチアップに時間を要します。また、既存のコンポーネントがあるのにその存在に気づかず、新たに作成したりし、ファイルサイズも大きくなり、パフォーマンスにも影響する可能性があります。さらに、Reduxなどの状態管理ツールなどが導入されていると、さらに複雑になります。

現在、僕が配属されたプロジェクトでも、ディレクトリ構成のルールはある程度あるものの、その判断基準も個々で曖昧で、ドキュメントとして残っておらず、テストコードもなく、結構複雑な構成をしております。また、Reduxも採用されており、スパゲッティコードまっしぐらな感じでした(;;)

そこで、これを機に、新たにデザインパターンのルールを決め、テストコードも導入しようと提案し、一旦自分の中で考えたデザインパターンを共有します。

環境

今回の対象となる環境は以下の通りです。

  • 開発言語・ライブラリ
    • React
    • TypeScript
  • 状態管理
    • ReduxToolKit
  • テスト
    • JEST
    • react testing library
    • msw

参考にしたデザインパターン

今回参考にしたデザインパターンは以下です。

詳しく見ていきます。

Atomic Design

コンポーネント駆動開発では、一番有名と言えるデザインパターンです。小さな部品から徐々にページを作成しようという考え(コンポーネント駆動開発)の、デザインパターンです。

Atomic Designでは以下の5つの構成に分けてます。

  • atoms(原子)
    • ボタン、入力要素、アイコン
  • molecules(分子)
    • 入力フォーム、アバター
  • organisms(生物)
    • ヘッダー、フッター、モーダル
  • templates
    • レイアウトのみの各ページ(データは非表示)
  • pages
    • データが注入された最終的にユーザが見る各ページ

依存関係は下から上になります。つまり、moleculesは、atomsから作成し、organismsは、atoms、moleculesより作成します。
atomsをmoleculesから作成するなどはあってはいけません。

Atomic Designは良い方法ではありますが、それをそのまま再現すると、問題が出てきます。
例えば、atomsなのかmolecules、はたまた、organismsかなのか?と悩む人ができきます。

なので、ルールをさらに定義してあげる必要があります。

今回は、5つに分けるのではなく、uiParts、uniqueParts、Pagesの3つに分けて、各ディレクトリにルールを作っていこうと思います。

MVCパターン

MVCは、Model、View、Controllerの頭文字をとったものです。ビュー部分とロジックの部分を分けることで、ビューの変更に強く、テストを容易にしたり、可読性を上げたりすることができます。

区分として以下のようになります。

  • Model
    • システムの中でビジネスロジックを担当する
  • View
    • 表示や入出力といった処理をする
  • Controller
    • ModelとViewを繋ぐ部分

ReactではPresentational and Container Componentsいう考えで、コンテナ部分とプレゼンテーション部分に分ける設計が有名ですが、これもMVCに則っている考えだと思います。
プレゼンター部分がView、コンテナ部分がController、hoooksです。
 
今回は、MVCなので、presenter.tsx(View)、container.tsx(Controller)、hooks.ts(Model)の3つに分けて実装したいと思います。

reducksパターン

Reduxを扱う際、Action、Reducerなどが出てきて、処理が結構複雑になることが多いです。Reducksパターンは、各ストアごとに、以下の区分に分けてファイルを構成することで、処理をわかりやすくします。

  • index.ts
    • 各種ファイルのモジュールエクスポート用
  • actions.ts
    • アクション定義
  • reducers.ts
    • リデューサー定義
    • ここのリデューサーは更新のみを行う簡単処理を記述
  • operation.ts
    • 非同期など複雑な処理
  • selectors.ts
    • 各種ストアの値を取得するための関数定
  • types.ts
    • ストアで扱う型定義

詳しくは、この記事を参考にしてください。

今回、ReduxToolKitを利用するので、action, reducerを分けずに作れますが、Reducksパターンをベースに利用することで可読性、堅牢性をあげた設計にしようと思います。
index.ts, slices.ts, operations.ts, selectors.ts, types.ts, initializes.tsの6ファイルに分割しようと思います。詳細は、後述します。

以上のデザインパターンをもとに、独自のルールを織り混ぜたデザインパターンを考えます。

考えたディレクトリ構造

新しく考案した、ディレクトリ構成をまず示します。

ディレクトリ構成
├── components
│   ├── pages # 各ページのコンポーネントを配置
│   │   └── [PageName]
│   │       ├── [ComponentName] # ページ特有のorganismsは限定コンポーネントとしてページ直下
│   │       │   ├── index.ts
│   │       │   ├── hooks.test.ts
│   │       │   ├── hooks.ts
│   │       │   ├── container.test.tsx
│   │       │   ├── container.tsx
│   │       │   ├── presenter.test.tsx
│   │       │   └── presenter.tsx
│   │       ├── index.ts
│   │       ├── hooks.ts
│   │       ├── hooks.test.ts
│   │       ├── container.tsx
│   │       ├── container.test.tsx
│   │       ├── presenter.tsx
│   │       └── presenter.test.tsx
│   ├── uiParts # 他プロジェクトでも利用可能な汎用パーツを格納
│   │   └── [ComponentName]
│   │       ├── index.ts
│   │       ├── hooks.ts
│   │       ├── hooks.test.ts
│   │       ├── container.tsx
│   │       ├── container.test.tsx
│   │       ├── presenter.tsx
│   │       └── presenter.test.tsx
│   └── uniqueParts  # organismsの内、ページをまたがる、かつ、プロジェクト特有のコンポーネント
│       └── [ComponentName]
│           ├── [ComponentName] # 各uniquePartsを更に分けたい場合は直下に置く
│           │   ├── index.ts
│           │   ├── hooks.test.ts
│           │   ├── hooks.ts
│           │   ├── container.test.tsx
│           │   ├── container.tsx
│           │   ├── presenter.test.tsx
│           │   └── presenter.tsx
│           ├── index.ts
│           ├── hooks.ts
│           ├── hooks.test.ts
│           ├── container.tsx
│           ├── container.test.tsx
│           ├── presenter.tsx
│           └── presenter.test.tsx
└── reducks # reducsパターンで管理
    ├── store
    │   ├── index.ts  # reducerの統合する記述などを行う
    └── [各store]
        ├── index.ts
        ├── operations.ts
        ├── initializes.ts
        ├── selectors.ts
        ├── types.ts
        └── slices.ts

以下詳ししくディレクトリ見ていきます。

uiParts

  • 汎用的なパーツを入れる
    • atomic designで言うと、atoms, moluculesの部分を担当する
    • ボタンや入力フォーム、汎用モーダルなど
    • material UIなどにあるものは全てuiPartsと考えると分類しやすい
  • 特定の用途に依存してはいけない(ドメインを持たない)
    • 他プロジェクトで利用できるか?を意識し、使えるものはuiPartsにする
  • Reduxなどのストア、Contextにアクセスしない
  • API通信の禁止
    • axios, useQueryの利用はダメ
  • UI表示表示に関するサービス特有のロジック以外は書かない
    • メールアドレスの検証ロジックなど
    • ラッピングしてビジネスロジックを含めたい場合は、後述のuniquePartsに作る
  • 配下にコンポーネント名のディレクトリ名を切ってその中に後述のファイル群を格納する
    • uiParts/Button/など

uniqueParts

  • 複数ページにまたがる特有のパーツ
    • ヘッダー、フッター、特定時のエラーモーダルなど
    • atomic designで言うとorganisms
    • uiPartsをもとに作成可能
  • ドメインを持つ
    • そのサービス特有のデータやロジックを持つ
  • サービスとして成り立つ部品
  • Reduxのストア、Contextにアクセスしても良い
  • ここの機能に関するAPIを叩くのも可能
  • uniqueParts/[ComponentName]/[ComponentName]には各種uniquePartsが大きくなりすぎるようであるなら、分けても良い
  • 配下にコンポーネント名のディレクトリ名を切ってその中に後述のファイルを格納する
    • uiParts/Header/など

pages

  • 各ページを作成する
    • uniqueParts, uiPartsを利用して各ページを作成する
  • ドメインを持つ
  • Reduxのストア、Contextにアクセスしても良い
  • API通信もOK
  • トップページ、検索ページなど各種ページ
  • 配下にページ名のディレクトリを切って、その中に後述のファイルを置く
    • pages/Top/など
  • pages/[PageName]/[ComponentName]にはこのページ特有のパーツを置く
    • 将来的にuniquePartsへの移行を考えて作成すること

reducks

  • reduxのストアを扱うディレクトリ
    • reducksパターンを利用しているため、reducksと言う名前を利用
  • ストアを適切なサイズに分割する必要がある
    • 大きくしすぎない

components内の各種ファイル構成

コンポーネントディレクトリにある各種ファイルについて説明します。

index.ts

  • コンポーネントをexporする
  • 外部より利用する際は、ここからimportして利用する
  • そのままexport defaultを利用しないことでプロジェクト内で名前を統一する効果がある
uiParts/Button/index.tsx
export { default as Button } from './container';
利用側
import { Buttton } from '../uiParts/Button'

presenter.tsx(DOM層)

  • 見た目とスタイルのみ記述
  • onClickした際の処理、useStateなどのフックなどの利用はしない
  • 引数の型定義は、ここで定義する

CSSスタイル処理は、TailwindCSSを利用するとDOM上に記述できるのでコード量も減り、命名する手間なく、パフォーマスも上がるのでおすすめです。

uiParts/Button/presenter.tsx
// container部分でも利用するためexport
export type ContanerProps = {
  className?: string;
}
// presetnrerでのみ利用するものを追加
type Props = {
  /** フラグ */
    flag: boolean;
  /** クリックされて時の処理 */
  onClick: (event: React.MouseEvent<EventTarget>) => void;
} & ContanerProps;

const ButtonPresentational: React.FC<Props> = props => (
  <div className={props.className}>
    <button onClick={props.handleClick} className="w-[100px] text-[1.5rem]">
      {props.flag ? 'trueだよ' : 'falseだよ'}
    </button>
  </div>
)
exprot default React.memo(ButtonPresentational)

presenter.test.tsx

  • presenter.tsxのテスト
  • 主に見た目に関するテスト
  • DOMのみなので、見た目のテストだけに集中できる
uiParts/Button/presenter.test.tsx
import { render, screen  from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import ButtonPresentational from "./presenter";


describe('Buttonンポーネントは表示切り替えのボタンコンポーネント', () => {
  test('フラグがfalseの時は「falseだよ」と表示', () => {
    render(<ButtonPresentational flag={false} onClick={jest.fn()} />);
    expect(screen.queryByText('falseです')).toBeInTheDocument();
  });
  test('フラグがtrueの時は「trueだよ」と表示', () => {
    render(<ButtonPresentational flag={true} onClick={jest.fn()} />);
    expect(screen.queryByText('trueです')).toBeInTheDocument();
  });
  test('ボタンが押されたらonClickが呼ばれる', async () => {
    const onClickMock = jest.fn();
    render(<ButtonPresentational flag={true} onClick={onClickMock} />);

    // クリック
    const user = userEvent.setup();
    const homeMenu = screen.getByText("trueです");
    await user.pointer({ target: homeMenu, keys: "[MouseLeft]" });

    expect(onClickMock).toHaveBeenCalledTimes(1);
  });
});

hooks.ts(ロジック、カスタムフック)

  • ビジネスロジック、カスタムフックを作成したらここに記述する
    • ソートなどの処理、検証のためロジックなど
  • 引数に対して処理を書くことを意識する
    • テストデータによりテストがしやすいようにするためである
hooks.ts
/** 
 * メールのフォーマットが正しいか検証
 */
function validateEmail(email: string) {
  const regex =
    /^[a-zA-Z0-9_+-]+(.[a-zA-Z0-9_+-]+)*@([a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]*\.)+[a-zA-Z]{2,}$/;
  return regex.test(str);
}
/**
 * トグルの値と更新関数
 */
export function useToggle() {
  const [flag, setFlag] = useState(false);
  const handleClick = useCallback(() => {
    setFlag((prevflag) => !prevflag);
  }, [flag]);

  return { flag, handleClick };
};

hooks.test.ts

  • hooks.tsのテストコード
hooks.test.ts
import { validateEmail, useToggle } from './hooks';
import { act,renderHook } from '@testing-library/react-hooks';


describe('validatePasswrodはパスワードを検証する', () => {
  test('test@gmail.comは正しいメールアドレスである', () => {
    expect(validateEmail('test@gmail.gom')).toBe(true);
  });
  test('testは正しいメールアドレスではない', () => {
    expect(validateEmail('test@gmail.gom')).toBe(true);
  });
});

describe('useToggleはトグルを作成する', () => {
  test('flagの初期値はfalseである', () => {
    const { result } = renderHook(() => useToggle());
    expect(result.current.flag).toBe(false);
  });
  test('hadleClickを1回呼び出すと、flagはtrueになる', () => {
    const { result } = renderHook(() => useToggle());
    act(() => {
      result.current.handleClick();
    });
    expect(result.current.flag).toBe(true);
  });
});

container.tsx(コンテナ層)

  • フックやビジネスロジックとDOM層を繋げる
import React, { useState ] from 'react';
import {ContainerProps, ButtonPresentational} from './presetner';

const ButtonContainer: React.FC<ContainerProps> = props => {
  const { flag, handleClick } = useToggle();

  return (
    <ButtonPresentational
      {...props}
      flag={flag}
      handleClick={handleClick}
    />
  )
}

export default ButtonContainer;

container.test.tsx

  • container.tsxのテスト
  • フックがAPIなどが入ってくるため、テストがややこしくなる傾向があるため、まずは、preseter.test.tsx,hook.test.tsxをしっかりテストすることが大事
uiParts/Button/container.test.tsx
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import React from 'react';
import { Button } from '.';

test('初期値は「falseだよ」と表示される', () => {
  render(<Button />);
  expect(screen.getByText(/falseだよ/i)).toBeInTheDocument();
});

reducks内のファイル構成

index.ts

  • export defaultを書く
index.ts
export { default as userSelectors } from "./selectors";
export { default as userOperations } from "./operations";
export { default as userTypes } from "./types";

selectors.ts

  • 取得のため関数を記述する
selectors.ts
import { createSelector } from 'reselect';
import { RootState } from '../store';

const userSelector = (state: RootState) => state.user;

/** ユーザメールアドレスの取得 */
export const loadUserEmail = createSelector(
  [userSelector],
  (state) => state.email
);
利用
import { useSelector } from 'react-redux';
import { loadUserEmail } from '../../../reducks/users/selectors';

const selector = useSelector((state) => state);
const email = loadUserEmail(selector);

slices.ts

  • reducers, actionsを担う
  • ストアの更新のみを記述すること
    • ロジックはここに記載してはダメ
slices.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { initialUserState } from '../store/initialState';
import type { User } from './types';

/**
 * ユーザー情報のスライス
 */
const userSlice = createSlice({
  name: 'user',
  initialState: initialUserState,
  reducers: {
    signInAction: (state: User, action: PayloadAction<User>) => {
      // 前回値に対して、データ変更箇所だけを更新しupdateDataに代入
      const updatedData = { ...state, ...action.payload };
      // ストアの値の更新
      return updatedData;
    },
  },
));

// actionをエクスポート
export const {
  signInAction,
} = userSlice.actions;

// reducerをエクスポート
export const user = userSlice.reducer;

operations.ts

  • 非同期処理などが含む複雑な処理を書く
    • APIより値を取得して、ストアを更新など
    • dispachが2つ以上処理するなど
operations.ts
import { Action } from '@reduxjs/toolkit';
import { push } from 'connected-react-router';
import { Dispatch } from 'react';
import { signInAction } from './slices';

/**
 * emailとパスワードでサイン処理するコールバック関数の定義
 * @param email Eメール
 * @param password パスワード
 * @returns ログイン処理のコールバック関数
 */
export function signIn(email: string, password: string) {
  return async (dispatch: Dispatch<Action>) => {
    try {
      const { user } = await signInWithEmailAndPassword(auth, email, password);

      // データベースよりユーザ情報取得
      const userRepository = new UserFirebaseRepository();
      const userData = await userRepository.fetchUser(user.uid);

      if (!userData) {
        throw new Error('サインインできたが、データベースに値がない');
      }
      dispatch(
        signInAction({
          customer_id: userData.customer_id,
          email: userData.email,
          isSignedIn: true,
          payment_method_id: userData.payment_method_id,
          role: userData.role,
          uid: user.uid,
          username: userData.username,
          favoriteProducts: userData.favorite_products,
          exhibitedProducts: userData.exhibited_products,
          purchasedProducts: userData.purchasedProducts,
        })
      );
      // サインインしたらトップページへ遷移
      dispatch(push('/'));
    } catch (error) {
      if (error instanceof Error) {
        throw new Error(error.message);
      }
    }
  };
}

types.ts

  • ストアで扱うデータの型を定義
types.ts
/** reduxで扱うユーザー情報の型 */
export type User = {
  customer_id: string;
  email: string;
  isSignedIn: boolean;
  role: string;
  payment_method_id: string;
  uid: string;
  username: string;
  favoriteProducts: string[];
  exhibitedProducts: string[];
  purchasedProducts: string[];
};

initializes.ts

  • ストアで扱うデータの初期値
initializes.ts
import { User } from '../users/types';

/**
 * ユーザー情報の初期化
 */
export const initialUserState: User = {
  customer_id: '',
  email: '',
  isSignedIn: false,
  payment_method_id: '',
  role: 'customer',
  uid: '',
  username: '',
  favoriteProducts: [],
  exhibitedProducts: [],
  purchasedProducts: [],
};

まとめ

以上、簡単に紹介しました。
結構大規模開発、かつ、チーム開発よりに結構細かく区切っている部分もあるのかなって思っています。
なので、一旦これで運用してみて何か不都合があれば、柔軟に変更できればいいのかなって思っております。

また、このように運用してどうだったのかを、今後発信していけたらと思います!

もっとこうした方が良いよ!とか、その考え方は古いねなどのご意見があればよろしくお願いします:)

参考

https://zenn.dev/takepepe/articles/6978c067faab9e7d33c2

https://zenn.dev/takepepe/articles/howto-withstand-aging-react-component

https://note.com/tabelog_frontend/n/n07b4077f5cf3

https://noah.plus/blog/021/

https://github.com/jthegedus/re-ducks-examples

https://zenn.dev/nash/articles/e2cb3521f2ec53

Discussion

ログインするとコメントできます