Angular/React両方を経験して得られた堅牢なアプリケーション設計

puku0x

puku

Posted on September 22, 2020

Angular/React両方を経験して得られた堅牢なアプリケーション設計

Photo by All Bong on Unsplash

概要

アプリケーション開発において設計は非常に重要です。

本記事では Todo アプリを例に、これまで取り組んできた React アプリケーションのアーキテクチャや実装パターンを紹介します。

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

コンセプト

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

設計概要

下記に実装例を示します。

Data

アプリケーション内で扱うデータは用途に応じて区別しましょう。

🤔なぜ? 
GET リクエストと POST リクエストで型が違うことはよくあります。また、API の仕様変更に柔軟に対応するためです。

Model

アプリケーションへの「入力」を表現するデータ型です。定数やAPI のレスポンス等が該当します。

interface Todo {
  id: number;
  title: string;
  completed: boolean;
}
Enter fullscreen mode Exit fullscreen mode

DTO(Data Transfer Object)

アプリケーションからの「出力」を表現するデータ型です。API リクエスト等が該当します。

interface TodoCreateDto {
  title: string;
}

interface TodoUpdateDto {
  id: number;
  title: string;
  completed: boolean;
}
Enter fullscreen mode Exit fullscreen mode

参考: CQRS, NestJS

Service

ドメインに関するビジネスロジックは Service に記述します。

Service

実装は関数やオブジェクトでも構いませんが、class を用いた DI パターンは強力なのでお勧めです。

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

  fetchAll(offset?: number, limit?: number): Promise<Todo[]> {
    return this.http.get(url, params).then(/* 一覧データ */);
  }

  fetch(id: number): Promise<Todo> {
    return this.http.get(url, params).then(/* 詳細データ */);
  }

  create(todo: TodoCreateDto): Promise<Todo> {
    return this.http.post(url, body).then(/* 登録データ */);
  }

  update(id: number, todo: TodoUpdateDto): Promise<Todo> {
    return this.http.put(url, body).then(/* 更新データ */);
  }

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

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

この他、汎用的なロジックはユーティリティとして分離する場合があります。

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);
  };
}
Enter fullscreen mode Exit fullscreen mode

参考: Introduction to services and dependency injection - Angular

Store

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

Store

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

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

Facade

Facade は Store の実装をコンポーネントから隠すための中間層です。

Facade

Angular では Service、React では Hooks として実装すると良いでしょう。

export const useTodoListFacade = (arg: { offset?: number; limit?: number }) => {
  const { offset, limit } = arg;
  const history = useHistory();
  const location = useLocation();
  const dispatch = useDispatch<AppDispatch>();
  const todos = useSelector(todosSelector);
  const isFetching = useSelector(isFetchingSelector);

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

  const changeOffset = useCallback(
    (offset: number) => {
      const params = new URLSearchParams(location.search);
      params.set('offset', `${offset}`);
      history.push(`/todos?${params}`);
    },
    [history, location.search]
  );

  const changeLimit = useCallback(
    (limit: number) => {
      const params = new URLSearchParams(location.search);
      params.set('limit', `${limit}`);
      history.push(`/todos?${params}`);
    },
    [history, location.search]
  );

  useEffect(() => {
    fetchAll({ offset, limit });
  }, [offset, limit, fetchAll]);

  return {
    isFetching,
    todos,
    changeOffset,
    changeLimit,
    fetchAll,
  } as const;
};
Enter fullscreen mode Exit fullscreen mode

Facade から Service を呼び出すこともあります。

参考: NgRx + Facades: Better State Management

Presenter

Presentational Component 内のロジックを抽出したものが Presenter です。

Presenter

Presenter にはフォームの値やローカルな状態を持たせましょう。

interface FormValues {
  title: string;
  completed: boolean;
}
Enter fullscreen mode Exit fullscreen mode

参考: Formik, React Hook Form

Angular では Service、React では Hooks として実装すると良いでしょう。

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;
};
Enter fullscreen mode Exit fullscreen mode

参考: Model-View-Presenter with Angular

Params

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

Params

Angular では Service、React では Hooks として実装すると良いでしょう。

import { useLocation } from 'react-router-dom';

export const useTodoListParams = () => {
  const location = useLocation();
  const params = new URLSearchParams(location.search);
  const limitParam = params.get('limit') || '10';
  const offsetParam = params.get('offset') || '0';

  return {
    limit: +limitParam,
    offset: +offsetParam,
  } as const;
};
Enter fullscreen mode Exit fullscreen mode

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

/users?offset=0&limit=10
Enter fullscreen mode Exit fullscreen mode

Page Component

Page Component は Params から取得したデータを Container Component に渡します。

Page Component

冗長に見えますが「Container Component 以下では既に URL パラメータが解決されている」という状況を作り出すことでデバッグやテストを容易にする狙いがあります。

import { TodoListContainer } from './containers';
import { useTodoListParams } from './todo-list.params';

export const TodoListPage = memo(() => {
  const { offset, limit } = useTodoListParams();

  return <TodoListContainer offset={offset} limit={limit} />;
});
Enter fullscreen mode Exit fullscreen mode

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

/users/1
Enter fullscreen mode Exit fullscreen mode
interface RouterParams {
  id: number;
}

export const useTodoDetailParams = () => {
  const { id } = useParams<RouterParams>();

  return { id } as const;
};
Enter fullscreen mode Exit fullscreen mode
import { TodoDetailContainer } from './containers';
import { useTodoDetailParams } from './todo-detail.params';

export const TodoDetailPage = memo(() => {
  const { id } = useTodoDetailParams();

  return <TodoDetailContainer id={id} />;
});
Enter fullscreen mode Exit fullscreen mode

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

Container Component

Page Component がパースした値を入力として受け取ります。

Container Component

Facade 経由で Store の状態を Presentational Component に渡したり、Action を Dispatch したりします。

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

type Props = {
  id: number;
};

export const TodoUpdateContainer = (props: Props) => {
  const { id } = props;
  const { update } = useTodoUpdateFacade({ id });

  return todo ? <TodoUpdate todo={todo} onUpdate={update} /> : null;
};
Enter fullscreen mode Exit fullscreen mode

URL パラメータの変更は Facade で行いましょう。

参考: Presentational and Container Components

Presentational Component

Model を描画するコンポーネントです。

Presentational Component

前述した 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 <>...</>
}
Enter fullscreen mode Exit fullscreen mode

参考: 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
  • Params: xxx.params.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 = () => {...}
Enter fullscreen mode Exit fullscreen mode

参考: 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
      - 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.ts
            - todo-create.presenter.spec.tsx
            - index.ts
          - index.ts
        - containers/
          - todo-create/
            - todo-create.container.tsx
            - todo-create.container.spec.tsx
            - todo-create.facade.ts
            - todo-create.facade.spec.tsx
            - index.ts
          - index.ts
        - todo-create.page.tsx
        - todo-create.page.spec.tsx
        - todo-create.params.ts
        - todo-create.params.spec.tsx
        - index.ts
      - todo-detail/
      - todo-list/
      - todo-update/
      - todo.route.tsx
      - index.ts
    - index.ts
  - shared/
    - components/
    - hooks/
    - utils/
    - index.ts
Enter fullscreen mode Exit fullscreen mode

参考: 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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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

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

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

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

tsconfig.json

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

"compilerOptions": {
  "strict": true
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

参考: AtomicDesign 境界線のひき方

ビルドツール

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

参考: React License Violation

終わりに

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

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

💖 💪 🙅 🚩
puku0x
puku

Posted on September 22, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related