はじめに
以下のような考えを満たすような環境を作ってみました。
- フロントエンドの環境でもクリーンアーキテクチャのようなことをしたい
- 非同期処理はモダンなreact-queryで書きたい
- 今まで慣れ親しんだreduxも入れときたい
コードはこちらにあります
https://github.com/pokotyan/clean-architecture-front
利用パッケージ
利用した主なパッケージなどは以下の通りです。
-
Context
diコンテナーとして利用 -
class-transformer、class-validator
モデルの生成、バリデーションに利用 -
@reduxjs/toolkit
reduxの導入に利用。グローバルなstateの管理をする -
normalizr
reduxのストアの正規化に利用 -
react-query、Suspense、ErrorBoundary
非同期処理、エラーハンドリングに利用 -
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でも同じことができると思います)
作ったアプリ
サンプルとして作ったものは、以下のようなダミーのデータを元に一覧ページ、詳細ページが閲覧できるものです。
データ
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",
},
},
],
},
];
一覧ページ
詳細ページ
ディレクトリ構成
ディレクトリ構成はこんな感じです。
├── 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を利用しました。
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
]
}
}
normalizrのスキーマ定義
normalizrで利用するスキーマや正規化、非正規化した後の型は自身で書く必要があります。
今回はこんな感じの型を用意しました。
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のモデルを返すようになっています。
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することができるようになります。
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できます。
export interface Factory {
create<T>(data: any): Promise<T>;
}
factory
factoryは引数でもらった非正規化したストアの値を元に、モデルを返します。
モデルの生成にclass-transformer、モデルのバリデーションにclass-validatorを使っています。
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-transformer、class-validatorを用いて生成するモデルです。
このモデルをコンポーネント上で扱って、画面を作っていくことになります。
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");
}
}
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");
}
}
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して、ユースケースのインスタンスを生成する層です。
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に流し込み、利用したいコンポーネント上で取り出せるようにします。
ユースケースのインスタンスをまとめたエントリーポイントを作ります。
import { NewBlogUseCase } from "./blog";
export default {
usecase: {
blog: NewBlogUseCase(),
},
};
上記のエントリーポイントを値としたContextを作り、カスタムフックとして利用できるようにします。
import React, { useContext } from "react";
import di from "../domain/di";
export const DIContext = React.createContext(di);
export const useDIContext = () => {
return useContext(DIContext);
};
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-query、Suspenseを利用しました。
react-query
react-queryのオプションでグローバルにsuspenceを有効化しておきます。
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引数に渡してデータを取得します。
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が必ずある状態の型が返って来るようにしました。
このフックはこちらの記事の実装をそのまま利用させていただきました。
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
に指定したコンポーネントが表示されます。ここではグルグル回るスピナーを表示するようにしています。
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を利用しました。
こんな感じのコンポーネントを用意し、
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で包みます。
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をスローするようにしていました。ここでエラーを起こしてみて、どうなるか確認します。
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レスポンスデータを不正な値にしてみます。
すると、こんな感じで、ErrorBoundaryでキャッチできていることがわかります。
ルーティング
ルーティングにはreact-router@v6を利用しました。
react-query、Suspenseの組み合わせで非同期処理をしていくのは今後増えていきそうですが、v5以前のreact-routerではSuspenseとの相性が悪いようで、react-query、Suspenseを使いたいならv6を入れておいた方が後々楽だと思います。参考
v6ではv3のときにあったようなルーティングを一箇所にネストして定義するやり方(nested routes)もできますし、v5の時のような各コンポーネントに分散してルーティングを書くこともできます。今回のサンプルアプリではv5の時のような各コンポーネントに分散して書く形で実装しました。
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;
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
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
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の型は正規化したデータの型を指定しています。
import { NormalizedBlogs } from "../../domain/api/blog/get/schema";
const initialState: NormalizedBlogs = {
entities: {
users: {},
comments: {},
blogs: {},
},
result: {
blogs: [],
},
};
最後に
今回思いつきで作ってみましたが、実際にこのアーキテクチャで業務コードを書いたことはないため、スケールしていくコードにどれだけ耐えれる設計なのかは実際に試してみようと思いました。
また、normalizrは実際業務で使ったことはなく、初めて使ってみたのですがスキーマなどの型定義が結構めんどくさく、「ストアの正規化ってそこまで恩恵あるのかな??」と懐疑的な気持ちになりました。