loading...
Cover image for Angular使いによるReactアプリケーションアーキテクチャ

Angular使いによるReactアプリケーションアーキテクチャ

puku0x profile image puku ・5 min read

Photo by Kevin Lanceplaine on Unsplash

概要

本記事では Todo アプリを例に、昨年から取り組んできた React アプリケーションのアーキテクチャを紹介します。

このアーキテクチャは筆者の Angular アプリケーション開発の経験が元になっており、Angular のオピニオンや Angular コミュニティで紹介された設計手法が含まれています。

コンセプト

コンポーネントとロジックの分離を基本とし、依存関係とデータフローを単方向にします。

データの種類

Model

API レスポンスの型や Service からの出力に相当します。定数も Model に分類します。

interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

DTO (Data Transfer Object)

API リクエストの型や Service への入力に相当します。API の仕様変更に柔軟に対応できるよう、DTO と Model は明確に区別し、登録と更新で別々に作りましょう。

interface TodoCreateDto {
  title: string;
}

interface TodoUpdateDto {
  id: number;
  title: string;
  completed: boolean;
}

参考: CQRS, NestJS

Form Value

フォームの値を表現するデータです。Presentational Component と Presenter でのみ使用されます。Angular では使いません。

interface FormValues {
  title: string;
  completed: boolean;
}

参考: Formik, React Hook Form

Service

Service には各ドメインに関するビジネスロジックを記述します。実装は関数やオブジェクトでも構いませんが、class を使った DI パターンは強力なのでお勧めです。

export class TodoService {
  constructor(private readonly http: HttpClient) {}

  fetchAll(offest?: number, limit?: number): Promise<Todo[]> {
    return /* 一覧データ */;
  }

  fetch(id: number): Promise<Todo> {
    return /* 指定されたIDのデータ */;
  }

  create(todo: TodoCreateDto): Promise<Todo> {
    return /* 登録されたデータ */;
  }

  update(todo: TodoUpdateDto): Promise<Todo> {
    return /* 更新されたデータ */;
  }

  remove(id: number): Promise<number> {
    return /* 削除されたデータのID */;
  }
}
// Axios や Fetch API のラッパー
export class HttpClient {
  ...
}

Service を実装する際は単一責任の原則を心がけましょう。CQRS に倣い、入力と出力で Service を分けても良いでしょう。

フレームワーク非依存且つドメイン知識を含まない汎用的な関数はユーティリティとして分離する場合があります。

export function debounce<T>(fn: (args: T) => void, delay: number) {
  let id: number | undefined;
  return (args: T) => {
    clearTimeout(id);
    id = window.setTimeout(() => fn(args), delay);
  };
}

参考: Introduction to services and dependency injection - Angular

Store

アプリケーション全体で使用する状態は Store に保存します。Store の実装は Angular では NgRx、React では Redux Toolkit + React Redux を使うと良いでしょう。

状態はイミュータブルかつ、Reducer が副作用を持たないようにしましょう。フォームの状態は後述する Presenter で持つのをお勧めします。

アプリケーションによっては Store が必要ない場合もあります。将来的に実装方法が変わる場合に備え、後述する Facade などの中間層を作っておくと良いでしょう。

Facade

Facade は Store の実装をコンポーネントから隠すための中間層です。Angular では Service、React では Hooks として実装すると良いでしょう。

export const useTodoFacade = () => {
  const dispatch = useDispatch<AppDispatch>();
  const todos = useSelector(todosSelector);
  const todo = useSelector(todoSelector);

  const fetchAll = useCallback((arg: { offset?: number; limit?: number; } = {}) => {
    return dispatch(fetchAllTodos(arg)).then(unwrapResult);
  }, [dispatch]);

  const fetch = useCallback((arg: { id: number }) => {
    return dispatch(fetchTodo(arg)).then(unwrapResult);
  }, [dispatch]);

  ...

  return {
    todos,
    todo,
    fetchAll,
    fetch,
    ...
  } as const;
};

参考: NgRx + Facades: Better State Management

Presenter

Presentational Component のロジックを抽出したものが Presenter です。Presenter にはフォームの値やローカルな状態を持たせましょう。Angular では Service、React では Hooks として実装すると良いでしょう。

interface FormValues {
  title: string;
  completed: boolean;
}

export const useTodoUpdatePresenter = (arg: { todo: Todo; onUpdate?: (todo: TodoUpdateDto) => void; }) => {
  const { todo, onUpdate } = arg;
  // const [counter, setCounter] = useState(0);

  // フォーム初期値
  const initialValues = useMemo(() => {
    return {
      title: todo.title,
      completed: todo.completed;
    } as FormValues;
  }, [todo]);

  // バリデーション用
  const validationSchema = useMemo(() => {
    return Yup.object().shape({
      title: Yup.string().required('Title is required.')
    });
  }, []);

  const formik = useFormik({
    enableReinitialize: true,
    initialValues,
    validationSchema,
    onSubmit: (values) => {
      const value = {...} as TodoUpdateDto;
      onUpdate && onUpdate(value);
    },
  });

  // const increment = useCallback(() => {
  //   setCounter(counter + 1);
  // }, [counter]);

  // const decrement = useCallback(() => {
  //   setCounter(counter - 1);
  // }, [counter]);

  return {
    ...formik,
    // counter,
    // increment,
    // decrement,
  } as const;
};

参考: Model-View-Presenter with Angular

Page Component

Page Component は Router から URL パラメータを取得し、Container Component 以下に渡します。

/users?offset=0&limit=10

ページネーションの状態や検索条件も URL パラメータに保存しましょう。

import { TodoListContainer } from './containers';

export const TodoListPage: React.FC = () => {
  const location = useLocation();
  const params = useMemo(() => {
    return new URLSearchParams(location.search);
  }, [location.search]);

  // URLパラメータから値を取得
  const offset = params.get('offset') || '0';
  const limit = params.get('limit') || '10';

  return <TodoListContainer offset={+offset} limit={+limit} />;
};

Page Component は使い回さず URL 毎に作成しましょう。

/users/1
import { TodoDetailContainer } from './containers';

interface RouterParams {
  id: string;
}

export const TodoDetailPage: React.FC = () => {
  const { id } = useParams<RouterParams>();

  return <TodoDetailContainer id={+id} />;
};

参考: Angular Web アプリケーションの最新設計手法

Container Component

Page Component がパースした値を props として受け取ります。Facade 経由で Store の状態を Presentational Component に渡し、Action を Dispatch します。Service を直接呼ぶこともあります。

import { TodoUpdate } from '../components';

type Props = {
  id: number;
};

export const TodoUpdateContainer: React.FC<Props> = (props) => {
  const { id } = props;
  const history = useHistory();
  const { todo, fetch, update } = useTodoFacade();

  // 更新
  const updateTodo = useCallback(
    (todo: TodoUpdateDto) => {
      update({ todo }).then((payload) => {
        const { todo } = payload;
        history.push(`/todos/${todo.id}`); // 詳細ページに遷移
      });
    },
    [history, update]
  );

  // 読み込み
  useEffect(() => {
    fetch({ id });
  }, [id, fetch]);

  return todo ? <TodoUpdate todo={todo} onUpdate={updateTodo} /> : null;
};

検索の実行などで URL パラメータを変更する場合は Container Component から操作しましょう。

参考: Presentational and Container Components

Presentational Component

Model や Form Value を描画するコンポーネントです。前述した Presenter やユーティリティ関数や Service の静的メソッドを呼ぶ場合がありますが、基本的にPresentational Component にはロジックを書かず描画に専念させましょう。

import { useTodoUpdatePresenter } from './todo-update.presenter';

type Props = {
  todo: Todo;
  onUpdate?: (todo: TodoUpdateDto) => void;
};

export const TodoUpdate: React.FC<Props> = (props) => {
  const { todo, onUpdate } = props;

  const {
    errors,
    values,
    handleChange,
    handleSubmit,
    ...
  } = useTodoUpdatePresenter({ todo, onUpdate });

  return <>...</>
}

参考: Presentational and Container Components

スタイルガイド

ほぼ Angular coding style guide と同じです。これは、React に足りないオピニオンを Angular から取り入れることで意思決定コストを下げるという狙いがあります。

命名規則

Angular coding style guide に倣い、ファイル名は kabab-case に統一しましょう。この命名規則は検索性に優れるため Angular 以外のプロジェクトでも有用です。

  • Model: xxx.model.ts
  • Service: xxx.service.ts
  • Hooks: xxx.hook.ts
  • Presenter: xxx.presenter.ts
  • Facade: xxx.facade.ts
  • Store
    • State: xxx.state.ts
    • Selector: xxx.selector.ts
    • Reducer: xxx.reducer.ts
    • Action: xxx.action.ts
  • Routing Component: xxx.route.tsx
  • Page Component: xxx.page.tsx
  • Container Component: xxx.container.tsx
  • Presentational Component: xxx.component.tsx
  • Tests: xxx.service.spec.ts, xxx.component.spec.tsx

このほか、class 名やコンポーネント名は PascalCase、関数は camelCase に統一しましょう。

コンポーネント名のサフィックスは React の場合だと冗長なので消してしまって良いかもしれません。

// Angular
@Component({...})
export class TodoListContainerComponent {}
@Component({...})
export class TodoListComponent {}

// React
export const TodoListContainer: React.FC = () => {...}
export const TodoList: React.FC = () => {...}

参考: Angular, TypeScript Deep Dive

ディレクトリ構成

Model、Service、Store、Page を起点にドメイン別にディレクトリを分けましょう。ユニットテストはテスト対象となるファイルと同じディレクトリに配置します(コロケーション)。アプリケーション全体で共有するコンポーネントやユーティリティは shared 等に入れると良いでしょう。

- src/
  - models/
    - todo/
      - todo.model.ts
      - index.ts
    - index.ts
  - services/
    - todo/
      - todo.service.ts
      - todo.service.spec.ts
      - index.ts
    - index.ts
  - store/
    - todo/
      - actions/
        - todo.action.ts
        - todo.action.spec.ts
        - index.ts
      - facades/
        - todo.facade.ts
        - todo.facade.spec.ts
        - index.ts
      - reducers/
        - todo.reducer.ts
        - todo.reducer.spec.ts
        - index.ts
      - selectors/
        - todo.selector.ts
        - todo.selector.spec.ts
        - index.ts
      - states/
        - todo.state.ts
        - index.ts
      - index.ts
    - index.ts
  - pages/
    - todo/
      - todo-create/
        - components/
          - todo-create/
            - todo-create.component.tsx
            - todo-create.component.spec.tsx
            - todo-create.presenter.tsx
            - todo-create.presenter.spec.tsx
            - index.ts
        - containers/
          - todo-create/
            - todo-create.container.tsx
            - todo-create.container.spec.tsx
            - index.ts
        - todo-create.page.tsx
        - todo-create.page.spec.tsx
        - index.ts
      - todo-detail/
      - todo-list/
      - todo-update/
      - todo.route.tsx
      - index.ts
    - index.ts
  - shared/
    - components/
    - hooks/
    - utils/
    - index.ts

参考: Angular, React

その他推奨する規約

TypeScript 自体の書き方に関しては TypeScript Deep Dive 等を参考にします。基本は ESLint/TSLint と Prettier によって自動的に決定されるため混乱は少ないと思われます。

  • Default export ではなく、Named export を使いましょう。

参考: なぜ default export を使うべきではないのか? - LINE ENGINEERING

  • enum ではなく、union 型を使いましょう。

参考: さようなら、TypeScript enum - Kabuku Developers Blog

  • any ではなく、unknown を使いましょう。

その他

Routing Component

react-router-dom を利用する場合、ルーティング用のコンポーネントが必要です。Angular の xxx-routing.module.ts に相当します。

import { TodoCreatePage } from './todo-create';
import { TodoDetailPage } from './todo-detail';
import { TodoListPage } from './todo-list';
import { TodoUpdatePage } from './todo-update';

export const TodoRoute: React.FC = () => {
  return (
    <Suspense fallback={<div>loading...</div>}>
      <Switch>
        <Route exact path="/todos" component={TodoListPage} />
        <Route exact path="/todos/new" component={TodoCreatePage} />
        <Route exact path="/todos/:id" component={TodoDetailPage} />
        <Route exact path="/todos/:id/edit" component={TodoUpdatePage} />
      </Switch>
    </Suspense>
  );
};

バンドルの肥大化を防ぐため、Routing Component は必ず動的インポートしましょう。Page Component も同様にすると良いでしょう。

export const TodoPage = React.lazy(() =>
  import('./todo.route').then((m) => ({ default: m.TodoRoute }))
);

アプリケーション全体のルーティングを管理するコンポーネントに渡します。

export const App: React.FC = () => {
  return (
    <Suspense fallback={<div>loading...</div>}>
      <Switch>
        <Route path="/todos" component={TodoPage} />
        <Route path="/users" component={...} />
        <Route path="/settings" component={...} />
      </Switch>
    </Suspence>
  );
};

tsconfig.json

any を許可しないようにしましょう。

"compilerOptions": {
  "strict": true
}

Atomic Design

Atomic Design はコンポーネント指向を理解するのに有用ですが、アプリケーション実装に際して管理面でのデメリットが多いと判断したため採用を見送りました。

Atomic Design のような設計手法が必要になるのは UI ライブラリを構築する時と考えられますが、その場合のディレクトリ構成は以下のようにすると良いでしょう。

- libs/
  - ui-components/
    - button/
      - button.component.tsx
      - button.component.spec.tsx
      - index.ts
    - icon/
    - input/
    - search-input/
    - select/
        - option/
          - option.component.tsx
          - option.component.spec.tsx
          - index.ts
      - select.component.tsx
      - select.component.spec.tsx
      - index.ts
    - index.ts

components/molecules のように 粒度だけでディレクトリを分ける のは絶対にやめましょう。

参考: AtomicDesign 境界線のひき方

ビルドツール

create-react-app を使ってビルドした場合、MIT ライセンスに違反するため、eject して webpack.config.js を修正するか、Nx 等の他ツールに移行するのを強くお勧めします。

参考: React License Violation

終わりに

React を始めた当初、アプリケーションをどのように設計すれば良いか分からず苦労しましたが、過去に携わった Angular アプリケーションでの設計手法や Angular コミュニティを通して得た知識が役に立ちました。

本記事で紹介したアーキテクチャは React アプリケーション用に作成しましたが、もちろん Angular アプリケーションにも適用可能です。これから Angular や React で開発を始める際の参考になれば幸いです。

Posted on by:

puku0x profile

puku

@puku0x

Software developer, ng-fukuoka organizer

Discussion

pic
Editor guide