LoginSignup
31
26

More than 3 years have passed since last update.

クリーンアーキテクチャ、react-query、redux-toolkitなフロントエンドの環境を作る

Last updated at Posted at 2020-09-27

はじめに

以下のような考えを満たすような環境を作ってみました。

  • フロントエンドの環境でもクリーンアーキテクチャのようなことをしたい
  • 非同期処理はモダンなreact-queryで書きたい
  • 今まで慣れ親しんだreduxも入れときたい

コードはこちらにあります
https://github.com/pokotyan/clean-architecture-front

利用パッケージ

利用した主なパッケージなどは以下の通りです。

  • tsyringe
    diに利用。typediinversifyでも同じことができると思います。

  • Context
    diコンテナーとして利用

  • class-transformerclass-validator
    モデルの生成、バリデーションに利用

  • @reduxjs/toolkit
    reduxの導入に利用。グローバルなstateの管理をする

  • normalizr
    reduxのストアの正規化に利用

  • react-querySuspenseErrorBoundary
    非同期処理、エラーハンドリングに利用

  • react-router@v6
    ルーティングに利用。v6のインストールは現状 @next を指定する必要があります。
    Suspenceをつかった非同期処理を行う場合、v5以前のものでは相性が悪いらしく、v6を利用しました。参考

  • create-react-app
    アプリの土台にはcreate-react-appを利用しました。
    npx create-react-app my-app --template redux-typescript を実行し、redux-toolkitとtypescriptが組み込まれたテンプレートを元に今回のアプリを作成しています。
    また、diツールのtsyringeを利用する場合、babelのプラグインを追加する必要があったのですが、create-react-appの設定をcracoを用いて上書きしています。(craの設定上書きはreact-app-rewiredでも同じことができると思います)

作ったアプリ

サンプルとして作ったものは、以下のようなダミーのデータを元に一覧ページ、詳細ページが閲覧できるものです。

データ

src/domain/api/blog/get/index.ts
const blogsData = [
  {
    id: 123,
    user: {
      id: 1,
      name: "Tom",
    },
    title: "引越し",
    body: "引越ししました",
    createdAt: dayjs(),
    comments: [
      {
        id: 324,
        body: "どこに?",
        createdAt: dayjs().add(3, "hour"),
        user: {
          id: 2,
          name: "Bob",
        },
      },
      {
        id: 325,
        body: "今度、家いかせて",
        createdAt: dayjs().add(5, "hour"),
        user: {
          id: 3,
          name: "Sam",
        },
      },
    ],
  },
];

一覧ページ

スクリーンショット 2020-09-27 10.50.40.png

詳細ページ

スクリーンショット 2020-09-27 10.50.51.png

ディレクトリ構成

ディレクトリ構成はこんな感じです。

├── src
│   ├── App.tsx
│   ├── ErrorBoundary.tsx // エラーハンドリングのためのコンポーネント
│   ├── components
│   │   ├── containers  // データとコンポーネントの受け渡しをする層
│   │   │   ├── BlogDetail.tsx
│   │   │   └── BlogList.tsx
│   │   ├── oraganisms  // propsでもらったデータをただ表示するコンポーネント
│   │   │   ├── BlogDetail.tsx
│   │   │   └── BlogList.tsx
│   │   └── pages       // ページのコンポーネント
│   │       └── Blog.tsx
│   ├── domain
│   │   ├── api          // apiを叩いてデータを取って来る層
│   │   │   ├── blog
│   │   │   └── schema.ts
│   │   ├── di           // diをする層
│   │   │   ├── blog.ts
│   │   │   └── index.ts
│   │   ├── model        // ドメインロジックを詰めるモデル層
│   │   │   └── blog
│   │   ├── repository   // reduxのstore情報を元にしてモデルを返す
│   │   │   ├── blog
│   │   │   └── index.ts
│   │   └── usecase      // ユースケース層
│   │       └── blog
│   ├── features         // reduxのaction、reducer
│   │   └── blog
│   │       ├── action.ts
│   │       └── reducer.ts
│   ├── hooks            // カスタムフック
│   │   ├── useDI.ts
│   │   └── useQueryWithSuspense.ts
│   ├── index.tsx
│   └── store.ts         // reduxのstore生成

ここからは順を追って、それぞれのディレクトリの処理を見ていきます。

apiからのデータ取得、正規化、reduxのストアへの格納

getBlogs関数は、apiからデータを取得してきたていの関数です。モックのデータをPromiseに包んで返します。

取得したデータはreduxのストアに格納します。
reduxのストアにはapiから取得したデータをそのまま格納するのではなく、正規化した値を入れるのがいいとされているのでそのようにしました。参考

正規化した値を入れることで、データのネスト構造が浅く保てるので、reducerで更新処理がわかりやすくなります。
正規化にはnormalizrを利用しました。

src/domain/api/blog/get/index.ts
import { normalize } from "normalizr";
import dayjs from "dayjs";
import {
  NormalizedBlogs,
  blogSchema,
  NormalizedBlog,
  Entities,
} from "./schema";
import { store } from "../../../../store";
import blogAction from "../../../../features/blog/action";

const blogsData = [
  {
    id: 123,
    user: {
      id: 1,
      name: "Tom",
    },
    title: "引越し",
    body: "引越ししました",
    createdAt: dayjs(),
    comments: [
      {
        id: 324,
        body: "どこに?",
        createdAt: dayjs().add(3, "hour"),
        user: {
          id: 2,
          name: "Bob",
        },
      },
      {
        id: 325,
        body: "今度、家いかせて",
        createdAt: dayjs().add(5, "hour"),
        user: {
          id: 3,
          name: "Sam",
        },
      },
    ],
  },
];

export const getBlogs = (): Promise<NormalizedBlogs> => {
  const normalizedBlogs = normalize<
    NormalizedBlog,
    Entities,
    { blogs: number[] }
  >({ blogs: blogsData }, { blogs: [blogSchema] });
  store.dispatch(blogAction.set(normalizedBlogs));

  return Promise.resolve(normalizedBlogs);
};

こんな感じで、apiから取得してきた想定のブログのデータをnormalizeで正規化し、

  const normalizedBlogs = normalize<
    NormalizedBlog,
    Entities,
    { blogs: number[] }
  >({ blogs: blogsData }, { blogs: [blogSchema] });

正規化した値をストアに格納しています。

store.dispatch(blogAction.set(normalizedBlogs));

normalizrで正規化したデータはこんな感じになって、ストアに格納されます。apiからとってきたデータはネストした構造でしたが、ネストしていた部分は該当データへのid参照のみになっていることがわかります。

{
  "entities": {
    "users": {
      "1": {
        "id": 1,
        "name": "Tom"
      },
      "2": {
        "id": 2,
        "name": "Bob"
      },
      "3": {
        "id": 3,
        "name": "Sam"
      }
    },
    "comments": {
      "324": {
        "id": 324,
        "body": "どこに?",
        "createdAt": "2020-09-27T04:59:27.926Z",
        "user": 2
      },
      "325": {
        "id": 325,
        "body": "今度、家いかせて",
        "createdAt": "2020-09-27T06:59:27.927Z",
        "user": 3
      }
    },
    "blogs": {
      "123": {
        "id": 123,
        "user": 1,
        "title": "引越し",
        "body": "引越ししました",
        "createdAt": "2020-09-27T01:59:27.926Z",
        "comments": [
          324,
          325
        ]
      }
    }
  },
  "result": {
    "blogs": [
      123
    ]
  }
}

スクリーンショット 2020-09-27 11.02.20.png

normalizrのスキーマ定義

normalizrで利用するスキーマや正規化、非正規化した後の型は自身で書く必要があります。
今回はこんな感じの型を用意しました。

src/domain/api/blog/get/schema.ts
import { schema, NormalizedSchema } from "normalizr";

type ListEntities<
  K extends string | number | symbol,
  T,
  U extends string
> = {
  [k in U]: Record<K, T>;
};


// apiレスポンス
export type BlogResponse = {
  id: number;
  user: {
    id: number;
    name: string;
  };
  title: string;
  comments: {
    id: number;
    body: string;
    user: {
      id: number;
      name: string;
    };
  }[];
};
export type BlogsResponse = BlogResponse[];

// normalizrで使うスキーマ
export const userSchema = new schema.Entity<NormalizedUser>("users");
export const commentSchema = new schema.Entity<Comment>("comments", {
  user: userSchema,
});
export const blogSchema = new schema.Entity<NormalizedBlog>("blogs", {
  user: userSchema,
  comments: [commentSchema],
});

// normalizeされた後の型定義
export type NormalizedBlogs = NormalizedSchema<Entities, { blogs: number[] }>;

export type Entities = BlogEntities & UserEntities & CommentEntities;

export interface NormalizedBlog {
  id: number;
  user: number;
  title: string;
  comments: number[];
}
export type BlogsStore = Record<number, NormalizedBlog>;
export type BlogEntities = ListEntities<number, NormalizedBlog, "blogs">;

export interface NormalizedComment {
  id: number;
  body: string;
  user: number;
}
export type CommentsStore = Record<number, NormalizedComment>;
export type CommentEntities = ListEntities<
  number,
  NormalizedComment,
  "comments"
>;

export interface NormalizedUser {
  id: number;
  name: string;
}
export type UsersStore = Record<number, NormalizedUser>;
export type UserEntities = ListEntities<number, NormalizedUser, "users">;

クリーンアーキテクチャ、DI

ドメインロジックをになっていく各層を作成していきます。
usecase層はreduxのストアに格納されている正規化したデータを引数としてもらい、そのデータを元にいろんなユースケースを実現していくイメージです。

│   ├── domain
│   │   ├── di           // diをする層
│   │   │   ├── blog.ts
│   │   │   └── index.ts
│   │   ├── model        // ドメインロジックを詰めるモデル層
│   │   │   └── blog
│   │   ├── repository   // reduxのstore情報を元にしてモデルを返す
│   │   │   ├── blog
│   │   │   └── index.ts
│   │   └── usecase      // ユースケース層
│   │       └── blog

usecase

usecaseに生えているメソッドが実際のコンポーネントから叩かれるメソッドになります。
こんな感じで、 Repository のinterfaceを満たすものをinjectionするようになっています。
findAll、findOneはBlogのモデルを返すようになっています。

src/domain/usecase/blog/index.ts
import { injectable, inject } from "tsyringe";
import type { Repository } from "../../repository";
import { Blog } from "../../model/blog/blog";
import { RootState } from "../../../store";

@injectable()
export default class BlogUseCase {
  constructor(@inject("BlogRepository") private repository: Repository) {}

  findAll(store: RootState["blog"]) {
    return this.repository.findAll<Blog>(store);
  }

  findOne(blogId: number, store: RootState["blog"]) {
    return this.repository.findOne<Blog>(blogId, store);
  }
}

Repositoryのinterfaceはこんな感じです。これを満たすものであればusecaseにdiすることができるようになります。

src/domain/repository/index.ts
export interface Repository {
  findAll<T>(store: any): Promise<T[]>;
  findOne<T>(id: number, store: any): Promise<T>;
}

今回はサンプルのアプリがシンプルすぎるので、usecaseのメソッド名とrepositoryのメソッド名が被ってますが、イメージ的にはユースケース層はいろんなrepository層をinjectionして、各モデルが協調して一つの処理を行うイメージです。
今回の例では、storeの値を引数に受け取り、モデルを返しているだけです。

repository

repositoryはreduxのストアの値を引数に、モデルを返します。モデルの生成はinjectionしているFactory層が担っています。
ストアの値は正規化されているので、このrepositoryの層で denormalize し、元のapiレスポンスの形に戻しています。
そのレスポンスを this.factory に渡すことでモデルが返ってきます。

import { injectable, inject } from "tsyringe";
import { denormalize } from "normalizr";
import { BlogResponse, blogSchema } from "../../api/blog/get/schema";
import type { Factory } from "../../model/blog/factory";
import type { Repository } from "..";
import type { RootState } from "../../../store";

@injectable()
export default class BlogRepository implements Repository {
  constructor(@inject("BlogFactory") private factory: Factory) {}

  findAll<T>(store: RootState["blog"]): Promise<T[]> {
    const denormalizedBlogs = denormalize(
      store.result.blogs,
      [blogSchema],
      store.entities
    );

    return Promise.all(
      denormalizedBlogs.map((blog: BlogResponse) => this.factory.create(blog))
    );
  }

  findOne<T>(id: number, store: RootState["blog"]): Promise<T> {
    const denormalizedBlog: BlogResponse = denormalize(
      id,
      blogSchema,
      store.entities
    );

    return this.factory.create(denormalizedBlog);
  }
}

Factoryのinterfaceはこんな感じです。
このinterfaceを満たすものであれば、repositoryにdiできます。

src/domain/model/blog/factory.ts
export interface Factory {
  create<T>(data: any): Promise<T>;
}

factory

factoryは引数でもらった非正規化したストアの値を元に、モデルを返します。
モデルの生成にclass-transformer、モデルのバリデーションにclass-validatorを使っています。

src/domain/model/blog/factory.ts
import { injectable } from "tsyringe";
import { plainToClass } from "class-transformer";
import { validate } from "class-validator";
import { Blog } from "./blog";
import type { BlogResponse } from "../../api/blog/get/schema";

export interface Factory {
  create<T>(data: any): Promise<T>;
}

@injectable()
export class BlogFactory {
  async create(denormalizedBlog: BlogResponse): Promise<Blog> {
    const model = plainToClass(Blog, denormalizedBlog);
    const errors = await validate(model);

    if (errors.length) {
      throw new Error(`モデルの作成に失敗しました。\n${errors}`);
    }

    return model;
  }
}

model

class-transformerclass-validatorを用いて生成するモデルです。
このモデルをコンポーネント上で扱って、画面を作っていくことになります。

src/domain/model/blog/blog.ts
import { Type } from "class-transformer";
import { IsInt, IsString, IsDateString, ValidateNested } from "class-validator";
import dayjs from "dayjs";
import { Author } from "./user";
import Comment from "./comment";

export class Blog {
  @IsInt()
  id: number;

  @Type(() => Author)
  @ValidateNested()
  user: Author;

  @IsString()
  title: string;

  @IsString()
  body: string;

  @Type(() => Comment)
  @ValidateNested()
  comments: Comment[];

  @IsDateString()
  createdAt: Date;

  get postDate(): string {
    return dayjs(this.createdAt).format("MMMM D, YYYY");
  }
}

src/domain/model/blog/comment.ts
import { Type } from "class-transformer";
import { IsInt, IsString, IsDateString, ValidateNested } from "class-validator";
import dayjs from "dayjs";
import { Commenter } from "./user";

export default class Comment {
  @IsInt()
  id: number;

  @IsString()
  body: string;

  @Type(() => Commenter)
  @ValidateNested()
  user: Commenter;

  @IsDateString()
  createdAt: Date;

  get postDate(): string {
    return dayjs(this.createdAt).format("MMMM D, YYYY");
  }
}

src/domain/model/blog/user.ts
import { IsInt, IsString } from "class-validator";

class User {
  @IsInt()
  id: number;

  @IsString()
  name: string;

  get initial(): string {
    return this.name[0];
  }
}

export class Author extends User {
  @IsInt()
  id: number;

  @IsString()
  name: string;
}

export class Commenter extends User {
  @IsInt()
  id: number;

  @IsString()
  name: string;
}

DI

作成したリポジトリ、ファクトリをDIして、ユースケースのインスタンスを生成する層です。

src/domain/di/blog.ts
import { container } from "tsyringe";
import BlogUseCase from "../usecase/blog";
import { BlogFactory } from "../model/blog/factory";
import BlogRepository from "../repository/blog";
import type { Repository } from "../repository";

export const NewBlogUseCase = () => {
  container.register("BlogFactory", { useClass: BlogFactory });
  container.register<Repository>("BlogRepository", {
    useClass: BlogRepository,
  });

  return container.resolve(BlogUseCase);
};

ユースケースを利用したい際に毎回 NewBlogUseCase を呼び出すのは手間です。
なので、ここで生成したユースケースのインスタンスはReactのContextに流し込み、利用したいコンポーネント上で取り出せるようにします。

ユースケースのインスタンスをまとめたエントリーポイントを作ります。

src/domain/di/index.ts
import { NewBlogUseCase } from "./blog";

export default {
  usecase: {
    blog: NewBlogUseCase(),
  },
};

上記のエントリーポイントを値としたContextを作り、カスタムフックとして利用できるようにします。

src/hooks/useDI.ts
import React, { useContext } from "react";
import di from "../domain/di";

export const DIContext = React.createContext(di);

export const useDIContext = () => {
  return useContext(DIContext);
};
src/index.tsx(抜粋)
import React from "react";
import App from "./App";
import di from "./domain/di";
import { DIContext } from "./hooks/useDI";

ReactDOM.render(
  <DIContext.Provider value={di}>
    <App />
  </DIContext.Provider>
  document.getElementById("root")
);

使う側ではこのようにしてユースケースを利用できます。

const di = useDIContext();
di.usecase.blog.findAll(data)

また、reduxを導入しているならContextは使わずに、reduxのstoreをDIコンテキストがわりにする、という考え方もあると思います。
しかし、reduxのstoreにはシリアライズ可能なプリミティブな値のみを格納する、という推奨があります。参考

そのため、今回のような NewBlogUseCase() で作成したインスタンスをreduxのstoreに格納するのはあまり推奨されません。
redux-toolkitを使っている場合、シリアライズできない値をstoreに格納する場合は、以下のような感じで serializableCheck: false を指定する必要があります。参考

import {
  configureStore,
  getDefaultMiddleware,
} from "@reduxjs/toolkit";
import blogReducer from "./features/blog/reducer";

export const store = configureStore({
  reducer: {
    blog: blogReducer,
  },
  middleware: [...getDefaultMiddleware({ serializableCheck: false })],
});

非同期処理

非同期処理には、react-querySuspenseを利用しました。

react-query

react-queryのオプションでグローバルにsuspenceを有効化しておきます。

src/index.tsx(抜粋)
import React from "react";
import ReactDOM from "react-dom";
import { ReactQueryConfig, ReactQueryConfigProvider } from "react-query";
import App from "./App";

const queryConfig: ReactQueryConfig = {
  shared: {
    suspense: true,
  },
};

ReactDOM.render(
  <ReactQueryConfigProvider config={queryConfig}>
    <App />
  </ReactQueryConfigProvider>
  document.getElementById("root")
);

こんな感じでuseQueryを利用します。
useDIContextで取得してきたユースケースをuseQueryのfetcher引数に渡してデータを取得します。

src/components/containers/BlogDetail.tsx
import React, { FC } from "react";
import { getBlogs } from "../../domain/api/blog/get";
import { useQueryWithSuspense } from "../../hooks/useQueryWithSuspense";
import BlogList from "../oraganisms/BlogList";
import BlogUseCase from "../../domain/usecase/blog";
import { NormalizedBlogs } from "../../domain/api/blog/get/schema";
import { useDIContext } from "../../hooks/useDI";

const fetchBlogModels = (usecase: BlogUseCase) => {
  return async (data: NormalizedBlogs) => {
    return usecase.findAll(data);
  };
};

const BlogListContainer: FC = () => {
  const { data: blogsData } = useQueryWithSuspense("getBlogs", getBlogs);
  const di = useDIContext();
  const { data: blogs } = useQueryWithSuspense(
    [blogsData, "getBlogModels"],
    fetchBlogModels(di.usecase.blog)
  );

  return <BlogList blogs={blogs} />;
};

export default BlogListContainer;

useQueryのfetcherの関数を高階関数にしている理由は、当初以下のようにインスタンスのメソッドを素直にfetcherに渡していましたが、

  const { data: blogs } = useQueryWithSuspense(
    [blogsData, "getBlogModels"],
    di.usecase.blog.findAll
  );

どうやらインスタンスのメソッドを渡すのは無理っぽく、これではエラーになりました。そのため、高階関数を渡すようにしています。

また、 useQueryWithSuspense は独自に用意したカスタムフックです。useQueryの結果のdataがnullableの型で推論されるのが面倒だったので以下のようなフックを用意し、dataが必ずある状態の型が返って来るようにしました。
このフックはこちらの記事の実装をそのまま利用させていただきました。

src/hooks/useQueryWithSuspense.ts
import {
  useQuery,
  QueryFunction,
  QueryKey,
  QueryResult,
  QueryConfig,
} from "react-query";

type RequireData<T extends { data: unknown }> = T & {
  data: NonNullable<T["data"]>;
};

type UseQueryWithSuspenseResult<T> = RequireData<QueryResult<T, unknown>>;

export const useQueryWithSuspense = <T extends unknown>(
  queryKey: QueryKey,
  fetcher: QueryFunction<T>,
  queryConfig?: QueryConfig<T>
): UseQueryWithSuspenseResult<T> => {
  return useQuery(queryKey, fetcher, queryConfig) as any;
};

Suspense

react-queryで非同期処理を行っているコンポーネントをSuspenseで囲ってあげることで、apiのデータ取得中は fallback に指定したコンポーネントが表示されます。ここではグルグル回るスピナーを表示するようにしています。

src/components/pages/Blog.tsx(抜粋)
import React, { Suspense } from "react";
import CircularProgress from "@material-ui/core/CircularProgress";
import BlogList from "../containers/BlogList";
import BlogDetail from "../containers/BlogDetail";

function BlogPage() {
  return (
    ...
            <Suspense fallback={<CircularProgress />}>
              <BlogList />
            </Suspense>
    ...
            <Suspense fallback={<CircularProgress />}>
              <BlogDetail />
            </Suspense>
    ...
  );
}

エラーハンドリング

非同期処理などのエラーハンドリング漏れを防ぐためにErrorBoundaryを利用しました。

こんな感じのコンポーネントを用意し、

src/ErrorBoundary.tsx
import React, { Component, ErrorInfo, ReactNode } from "react";

export interface Props {
  children: ReactNode;
}

export interface State {
  hasError: boolean;
  error: Error | null;
}

export default class ErrorBoundary extends Component<Props, State> {
  constructor(props: Props) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
    console.log(error, errorInfo);
  }

  render(): ReactNode {
    const { hasError, error } = this.state;
    const { children } = this.props;
    if (hasError) {
      return <h1 style={{ whiteSpace: "pre-wrap" }}>{error?.message}</h1>;
    }

    return children;
  }
}

エラーハンドリングしたいコンポーネントをErrorBoundaryで包みます。

src/components/pages/Blog.tsx
import React, { Suspense } from "react";
import { Route, Routes } from "react-router";
import CircularProgress from "@material-ui/core/CircularProgress";
import BlogList from "../containers/BlogList";
import BlogDetail from "../containers/BlogDetail";
import ErrorBoundary from "../../ErrorBoundary";

function BlogPage() {
  return (
    <ErrorBoundary>
      <Routes>
        <Route
          path=""
          element={
            <Suspense fallback={<CircularProgress />}>
              <BlogList />
            </Suspense>
          }
        ></Route>
        <Route
          path=":blogId"
          element={
            <Suspense fallback={<CircularProgress />}>
              <BlogDetail />
            </Suspense>
          }
        />
      </Routes>
    </ErrorBoundary>
  );
}

export default BlogPage;

ErrorBoundaryでエラーハンドリングをする例として、モデルを生成する際バリデーションに失敗するとErrorをスローするようにしていました。ここでエラーを起こしてみて、どうなるか確認します。

src/domain/model/blog/factory.ts(抜粋)
  async create(denormalizedBlog: BlogResponse): Promise<Blog> {
    const model = plainToClass(Blog, denormalizedBlog);

    const errors = await validate(model);

    if (errors.length) {
      throw new Error(`モデルの作成に失敗しました。\n${errors}`);
    }

    return model;
  }

意図的にモックのAPIレスポンスデータを不正な値にしてみます。
スクリーンショット 2020-09-29 8.43.28.png

すると、こんな感じで、ErrorBoundaryでキャッチできていることがわかります。
スクリーンショット 2020-09-27 11.54.29.png

ルーティング

ルーティングにはreact-router@v6を利用しました。
react-query、Suspenseの組み合わせで非同期処理をしていくのは今後増えていきそうですが、v5以前のreact-routerではSuspenseとの相性が悪いようで、react-query、Suspenseを使いたいならv6を入れておいた方が後々楽だと思います。参考

v6ではv3のときにあったようなルーティングを一箇所にネストして定義するやり方(nested routes)もできますし、v5の時のような各コンポーネントに分散してルーティングを書くこともできます。今回のサンプルアプリではv5の時のような各コンポーネントに分散して書く形で実装しました。

src/App.tsx
import React, { useEffect } from "react";
import { Route, Routes, useLocation, useNavigate } from "react-router";
import Blog from "./components/pages/Blog";
import "./App.css";

function App() {
  const { hash, pathname } = useLocation();
  const navigate = useNavigate();

  useEffect(() => {
    if (pathname === "/") {
      navigate("/blog");
    }
  }, [hash, pathname, navigate]);

  return (
    <Routes>
      <Route path="blog/*" element={<Blog />} />
    </Routes>
  );
}

export default App;

src/components/pages/Blog.tsx
import React, { Suspense } from "react";
import { Route, Routes } from "react-router";
import CircularProgress from "@material-ui/core/CircularProgress";
import BlogList from "../containers/BlogList";
import BlogDetail from "../containers/BlogDetail";
import ErrorBoundary from "../../ErrorBoundary";

function BlogPage() {
  return (
    <ErrorBoundary>
      <Routes>
        <Route
          path=""
          element={
            <Suspense fallback={<CircularProgress />}>
              <BlogList />
            </Suspense>
          }
        ></Route>
        <Route
          path=":blogId"
          element={
            <Suspense fallback={<CircularProgress />}>
              <BlogDetail />
            </Suspense>
          }
        />
      </Routes>
    </ErrorBoundary>
  );
}

export default BlogPage;

Redux toolkit

グローバルなstateの管理には@reduxjs/toolkitを利用しました。
普通にreduxを利用した場合はtypescriptとの相性やaction、reducerなどのボイラープレート的なコード量の多さに辟易していきますが、redux toolkitを利用した場合はそこらへんのめんどくささが結構解消されているため、使いやすく感じてます。(アクションとかは自分で書く必要はなくなるし、tsとの相性もいい)

ディレクトリ構造はfeaturesディレクトリのなかにactionとreducerをまとめるようにしています。

├── src
│   ├── features         // reduxのaction、reducer
│   │   └── blog
│   │       ├── action.ts
│   │       └── reducer.ts

action

src/features/blog/action.ts
import { NormalizedBlogs } from "../../domain/api/blog/get/schema";
import { RootState } from "../../store";
import { blogSlice } from "./reducer";

const { actions } = blogSlice;

export const selectBlog = (state: RootState): NormalizedBlogs => state.blog;

export const isEmpty = (state: RootState): boolean =>
  !Object.keys(state.blog.entities.blogs).length;

export default actions;

reducer

src/features/blog/reducer.ts
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { NormalizedBlogs } from "../../domain/api/blog/get/schema";

const initialState: NormalizedBlogs = {
  entities: {
    users: {},
    comments: {},
    blogs: {},
  },
  result: {
    blogs: [],
  },
};

export const blogSlice = createSlice({
  name: "blog",
  initialState,
  reducers: {
    set: (state, action: PayloadAction<NormalizedBlogs>): NormalizedBlogs => {
      return { ...state, ...action.payload };
    },
  },
});

export default blogSlice.reducer;

今回のアプリの設計では、reduxのstoreにはnormalizrで正規化したデータを格納します。
そのため、stateのinitialStateの型は正規化したデータの型を指定しています。

src/features/blog/reducer.ts(抜粋)
import { NormalizedBlogs } from "../../domain/api/blog/get/schema";

const initialState: NormalizedBlogs = {
  entities: {
    users: {},
    comments: {},
    blogs: {},
  },
  result: {
    blogs: [],
  },
};

最後に

今回思いつきで作ってみましたが、実際にこのアーキテクチャで業務コードを書いたことはないため、スケールしていくコードにどれだけ耐えれる設計なのかは実際に試してみようと思いました。
また、normalizrは実際業務で使ったことはなく、初めて使ってみたのですがスキーマなどの型定義が結構めんどくさく、「ストアの正規化ってそこまで恩恵あるのかな??」と懐疑的な気持ちになりました。

31
26
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
31
26