20200927のReactに関する記事は11件です。

ReactNative での環境切り替え時メモ

環境の切り替え対象

  • APIのエンドポイントなどの切り替え
    • react-native-config を使用する
  • Release, Debug ビルドでの設定の切り替え、Firebase の設定ファイル (GoogleService-Info.plist)
    • iOS, Android のビルド時の設定変更の機能を使用して、切り替える

react-native-config の導入

$ yarn add react-native-config
$ react-native link react-native-config
$ (cd ios; pod install)

iOSの環境の設定切り替え

  • こちら を参考にし、Target のコピー、Scheme 追加
  • 6 の pre-action step
    • PROJECT_DIR の .env を書き換えるため、ローカル実行時は ENVFILE=.env.local を使用するなど工夫する
  • react-run-ios --scheme "作成したTarget名" で起動する

GoogleService-Info.plist の配置

  • 各 Target の名前のディレクトリを作成しその配下に置く
  • XCode で GoogleService-Info.plist を選択 -> TargetMembership で読み込む Target を指定する

注意点

  • build の Info タブで React のチェックを外さないようにする
    • テスト等、コピー元のTargetにチェックが入っている場合、react-run-ios 時にそちらの app が起動してしまう
  • こちら を参考に Podfile の書き換えを行う

Android の環境の設定切り替え

バージョン/ビルドバージョンの書き換え用のコードの用意

yarn run set:version {次のバージョン番号} {次のビルド番号} のような形で起動

setVersion.js
const fs = require('fs');
const { execSync } = require('child_process');

const version = process.argv[2];
if (!version) {
  throw 'version が指定されていません';
}
const build = process.argv[3];
if (!build) {
  throw 'build が指定されていません';
}
console.log(`build: ${version}`);

// ファイル文字列置換
const versionFiles = [
  {
    name: 'src/constants.js',
    regex: /export const appVersion.*/,
    replace: `export const appVersion = "${version}";`
  },
  {
    name: 'android/app/build.gradle',
    regex: /    versionName .*/,
    replace: `    versionName "${version}"`
  },
  {
    name: 'android/app/src/main/AndroidManifest.xml',
    regex: /    android:versionName=.*/,
    replace: `    android:versionName="${version}">`
  }
];
const buildFiles = [
  {
    name: 'android/app/build.gradle',
    regex: /    versionCode .*/,
    replace: `    versionCode ${build}`
  },
  {
    name: 'android/app/src/main/AndroidManifest.xml',
    regex: /    android:versionCode=.*/,
    replace: `    android:versionCode="${build}"`
  }
];

console.log("\nreplace version");
for (const file of versionFiles) {
  const data = fs.readFileSync(file.name, 'utf8');
  const target = data.match(file.regex);
  console.log(`  file: ${file.name}`);
  console.log(`     ${target}`);
  console.log(`  -> ${file.replace}\n`);
  const replaced = data.replace(file.regex, file.replace);
  fs.writeFileSync(file.name, replaced, 'utf8');
}

console.log("\nreplace build");
for (const file of buildFiles) {
  const data = fs.readFileSync(file.name, 'utf8');
  const target = data.match(file.regex);
  console.log(`  file: ${file.name}`);
  console.log(`     ${target}`);
  console.log(`  -> ${file.replace}\n`);
  const replaced = data.replace(file.regex, file.replace);
  fs.writeFileSync(file.name, replaced, 'utf8');
}

// ios 設定変更
console.log("\nreplace version in xcode\n");
execSync(`cd ios/ && agvtool new-marketing-version ${version}`, {stdio: 'inherit'});
console.log("\nreplace build in xcode\n");
execSync(`cd ios/ && agvtool new-version -all ${build}`, {stdio: 'inherit'});

参考

https://github.com/luggit/react-native-config
https://dev.classmethod.jp/articles/xcode-build-environment-adding-scheme/
https://gist.github.com/jacks205/45b2721bf2a2b912b1c130aef2572820#ios-instructions

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Redux ExampleのTodoListをReact Native(expo)に置き換えて解説-ToggleTodo編

Redux ExampleのTodoListをReact Native(expo)に置き換えて解説のToggleTodo編です。
AddTodo編は以下です。
Redux ExampleのTodoListをReact Native(expo)に置き換えて解説AddTodo編

注:僕は掛け出しエンジニアであり、自分の勉強としての投稿という面もあるので、もしミスや勘違い、ベストプラクティスではない、等がありましたら、コメントしていただけると幸いです。

TODOに属性を追加

まずは、todoにcomplete属性を追加してtodoが完了済みなのかactive状態なのかを判定できるようにします。初期値はfalseにしておきます。

reducers/todos.js
const todoReducers = (state = [], action) => {
    switch(action.type){
        case 'ADD_TODO':
            return [
                ...state,
                {
                    id: action.id,
                    text: action.text,
                    completed: false //追加
                }
            ]
        default:
            return state
    }
}

export default todoReducers

Todoのcompleted属性がtrueかfalseによって見た目を変えたいので、TodoListコンポーネントを修正します。

components/TodoList.js
class TodoList extends Component {
  render() {
    return (
      <View>
        <FlatList
          data={this.props.todos}
          renderItem={({ item }) => (
            <View style={styles.todoList}>
              <Text
                style={{ 
                 textDecorationLine: item.completed ? "line-through" : "none",
                }} //追加↑
              >
                {item.text}
              </Text>
            </View>
          )}
          keyExtractor={(item) => item.id.toString()}
        />
      </View>
    );
  }
}

ここで、動作確認のため、reducers/todos.jsのcompleted属性をtrueにしてみましょう。todoに斜線が引かれていると思います。

Actionからcompleted属性を操作する

次に、上記でやったtrue,falseの操作をActionを経由して操作できるようにしましょう。

まずはActionCreatorを作成します。どのtodoに横線を引くかを判別するためにidを取得します。

src/actions/index.js
let nextTodoId = 0
export const addTodo = text => ({
    type: 'ADD_TODO',
    id: nextTodoId++,
    text
})    

//追加
export const toggleTodo = (id) => {
  return {
    type: "TOGGLE_TODO",
    id,
  };
};

reducersも変更します。

reducers/todos.js
const todoReducers = (state = [], action) => {
  switch (action.type) {
    case "ADD_TODO":
      return [
        ...state,
        {
          id: action.id,
          text: action.text,
          completed: true,
        },
      ];
    case "TOGGLE_TODO": //追加
      return state.map((todo) =>
        todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
      );
    default:
      return state;
  }
};

export default todoReducers;

やっていることとしては、stateに保存されている全てのtodoについて、それぞれのidとActionCreatorに渡された、横線を引きたいtodoのidを比べて、一致したらそのtodoのcompleted属性を逆転させるというものです。

ここで、例の如くApp.jsから動作確認をしてみましょう
App.jsに以下を追加してみましょう。

App.js
import { addTodo, toggleTodo } from './actions'

store.dispatch(addTodo('Hello React!'))
store.dispatch(toggleTodo(0))

画面をみてみると斜線が引かれたtodoが追加されているはずです。

Todoをクリックしてcompleted属性を操作できるようにする

それでは、画面上からtodoの完了、未完了の操作ができるようにしましょう。mapDispatchToPropsを作成します。
これはcomponentで(今回で言えばTodoListで)dispatchをpropsとして渡せるものです。

こうすることで、component側ではthis.props.onTodoClick(id)の形で先ほどApp.jsでやったdispatch(toggleTodo(0))みたいなことができるようになります。

containers/VisibleTodoList.js
import { connect } from "react-redux";
import TodoList from "../components/TodoList";
import { toggleTodo } from "../actions"; //追加

const mapStateToProps = (state) => {
  return { todos: state.todoReducers };
};

//追加
const mapDispatchToProps = (dispatch) => {
  return {
    onTodoClick: (id) => {
      dispatch(toggleTodo(id));
    },
  };
};

//追加
const VisibleTodoList = connect(mapStateToProps, mapDispatchToProps)(TodoList);
export default VisibleTodoList;

これでTodoListの方でonTodoClickが使えるようになったので、追加してみましょう。

components/TodoList.js
return (
      <View>
        <FlatList
          data={this.props.todos}
          renderItem={({ item }) => (
            <View style={styles.todoList}>
              <Text
                onPress={() => this.props.onTodoClick(item.id)}
                style={{
                  textDecorationLine: item.completed ? "line-through" : "none",
                }}
              >
                {item.text}
              </Text>
            </View>
          )}
          keyExtractor={(item) => item.id.toString()}
        />
      </View>
    );

これでtodoをクリックすると該当のtodoに斜線が引かれます。

ToggleTodo編は完成です!
お手元のシミュレーターでお試しください。
ここまでのソースコードはGitHubに上げていますのでご参考ください。

次回は表示するTodoをcompleted属性によって切り替える「FilterTodo」機能を実装していきます。

参考

Redux ExampleのTodo Listをはじめからていねいに(2)

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

next.jsでdotenvで環境変数を設定するのは間違い!たった3分で環境変数を設定する方法

概要

・next.jsでは「.env.local」ファイルで環境変数を設定できる。
・クライアント側でも環境変数を設定したいときは接頭語に「NEXT_PUBLIC_」をつける
・開発環境は「.env.development」、本番環境は「.env.production」でそれぞれの環境変数を設定できる

環境変数を設定する(サーバー編)

next.jsでは簡単に環境変数を変更する仕組みがあります。
ルートディレクトリに「.env.local」ファイルを置き、その中で定義した環境変数はアプリの中で使うことができます。

たとえば、開発環境と本番環境で異なるデータベースを使いますよね。そんな時に「.env.local」ファイルに設定すれば環境変数をアプリ内で利用できます。

env.local
DB_HOST=localhost
DB_USER=root
DB_PASSWORD=root
環境変数の使用.js
export async function getServerSideProps(context) {
  console.log(process.env.DB_HOST) //コンソールに「localhost」を出力(サーバー側)
  return {props: {}}
}

環境変数に他の環境変数を設定したい場合

また、「.env.local」で設定した環境変数を別の環境変数で使うには、「$」を使います。
たとえば、ホスト名にポート番号を追加したいときには以下のように定義します。

env.local
PORT=3000
HOST=http://localhost:$PORT
index.js
export async function getServerSideProps(context) {
  console.log(process.env.HOST) //コンソールに「http:localhost:3000」を出力(サーバー側)
  return {props: {}}
}

環境変数を設定する(クライアント編)

先ほどの、「.env.local」で設定した環境変数はnode.jsのみで適用されます。そのため、ブラウザ側で動くプログラムでは使うことができません、
クライアント側でも環境変数を使いたいときにはどうすればいいのでしょうか?

この問題を解決するのが環境変数の接頭語の「NEXT_PUBLIC_」です。環境変数の接頭語に「NEXT_PUBLIC_」をつければ、クライアント側でも使うことができます。
これにより、ブラウザ側、サーバー側どちらでも環境変数を利用できるのです。

env.local
NEXT_PUBLIC_TEST=client-server
index.js
export async function getServerSideProps(context) {
  console.log(process.env.NEXT_PUBLIC_TEST) //コンソールに「client-server」を出力(サーバー側)
  return {props: {}}
}

const index = () => {
  return <p>{process.env.NEXT_PUBLIC_TEST}</p>  //画面に「client-server」を出力(クライアント側)
}

export default index

環境によって読み込むファイルを変更する方法

環境ごとに読み込む環境変数を変更したい場合があります。
たとえば、開発環境では開発用のDBを使うけど、本番環境では本番用のDBを使いたいなど環境ごとにDBを切り替えたい場面などです。

そんな時は環境ごとのenvファイルを用意します。以下の4種類でそれぞれの環境変数を設定できます。
.env:全環境共通の環境変数
.env.development:開発環境(next dev)の環境変数
.env.production:本番環境(next start)の環境変数
.env.local:デフォルトの環境変数(すべての環境変数を上書き)

これの環境変数の優先順位は以下の通りです。
①.env.local
②.env.development/.env.production
③.env

基本的に、「.env」にデフォルトで全ての環境変数を設定して、「.env.development」、「.env.production」で上書きするのがいいのではないでしょうか?

参考

next.jsの環境変数の設定

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

クリーンアーキテクチャ、react-query、redux-toolkitな環境を作る

はじめに

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

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

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

利用パッケージ

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

作ったアプリ

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

データ

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 { DIContext } from "./hooks/useDI";

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

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

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

非同期処理

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

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;
};

エラーハンドリング

非同期処理などのエラーハンドリング漏れを防ぐために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;

例として、モデルを生成する際バリデーションに失敗するとErrorをスローするようにしていました。

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

    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
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;

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;

最後に

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

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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

はじめに

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

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

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

利用パッケージ

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

作ったアプリ

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

データ

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 { DIContext } from "./hooks/useDI";

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

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

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

非同期処理

非同期処理には、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);
    console.log(model);

    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は実際業務で使ったことはなく、初めて使ってみたのですがスキーマなどの型定義が結構めんどくさく、「ストアの正規化ってそこまで恩恵あるのかな??」と懐疑的な気持ちになりました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[MERN⑩] Posts & Comments

~~~~~~~~~~ (Contents) MERN ~~~~~~~~~~~
[MERN①] Express & MongoDB Setup
https://qiita.com/niyomong/private/3281af84486876f897f7
[MERN②]User API Routes & JWT Authentication
https://qiita.com/niyomong/private/c11616ff7b64925f9a2b
[MERN③] Profile API Routes
https://qiita.com/niyomong/private/8cff4e6fa0e81b92cb49
[MERN④] Post API
https://qiita.com/niyomong/private/3ce66f15375ad04b8989
[MERN⑤] Getting Started With React & The Frontend
https://qiita.com/niyomong/private/a5759e2fb89c9f222b6b
[MERN⑥] Redux Setup & Alerts
https://qiita.com/niyomong/private/074c27259924c7fd306b
[MERN⑦] React User Authentication
https://qiita.com/niyomong/private/37151784671eff3b92b6
[MERN⑧] Dashboard & Profile Management
https://qiita.com/niyomong/private/ab7e5da1b1983a226aca
[MERN⑨] Profile Display
https://qiita.com/niyomong/private/42426135e959c7844dcb
[MERN⑩] Posts & Comments
https://qiita.com/niyomong/private/19c78aea482b734c3cf5
[MERN11] デプロイ
https://qiita.com/niyomong/private/150f9000ce51548134ad
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

1. Post Reducer, Action & Initial Component

① GET_POSTSとPOST_ERRORアクションを設置

//...
export const ACCOUNT_DELETED = 'ACCOUNT_DELETED';
+ export const GET_POSTS = 'GET_POSTS';
+ export const POST_ERROR = 'POST_ERROR';

② postReducerを生成

reducers/post.js
//...
import profile from './profile';
+ import post from './post';

export default combineReducers({
  alert,
  auth,
  profile,
+  post,
});
reducers/post.js
import { GET_POSTS, POST_ERROR } from '../actions/types';

const initialState = {
  posts: [],
  post: null,
  loading: true,
  error: {},
};

export default function (state = initialState, action) {
  const { type, payload } = action;

  switch (type) {
    case GET_POSTS:
      return {
        ...state,
        posts: payload,
        loading: false,
      };
    case POST_ERROR:
      return {
        ...state,
        error: payload,
        loading: false,
      };
    default:
      return state;
  }
}

③ postアクション作成

actions/post.js
import axios from 'axios';
import { setAlert } from './alert';
import { GET_POSTS, POST_ERROR } from './types';

// Get posts
export const getPosts = () => async (dispatch) => {
  try {
    const res = await axios.get('/api/posts');
    dispatch({
      type: GET_POSTS,
      payload: res.data,
    });
  } catch (err) {
    dispatch({
      type: POST_ERROR,
      payload: { msg: err.response.statusText, status: err.response.status },
    });
  }
};

postsルートをApp.jsに設置

src/App.js
//...
+ import Posts from './components/posts/Posts';
import PrivateRoute from './components/routing/PrivateRoute';
//...
      <Switch>
        <Route exact path="/register" component={Register} />
        <Route exact path="/login" component={Login} />
+       <Route exact path="/posts" component={Posts} />
        <PrivateRoute exact path="/dashboard" component={Dashboard} />
//...

⑤ 左上のサービス名にPosts一覧リンクを設置

components/layout/Navbar.js
//...
  return (
    <nav className="navbar bg-dark">
      <h1>
+       <Link to="/posts">
          <i className="fas fa-code" /> Refnote
        </Link>
      </h1>

      {!loading && (
        <Fragment>{isAuthenticated ? authLinks : guestLinks}</Fragment>
//...

2. Post Item Component

① Postsコンポーネントを生成。

components/posts/Posts.js
import React, { Fragment, useEffect } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import Spinner from '../layout/Spinner';
import PostItem from './PostItem';
import { getPosts } from '../../actions/post';

const Posts = ({ getPosts, post: { posts, loading } }) => {
  useEffect(() => {
    getPosts();
  }, [getPosts]);

  return loading ? (
    <Spinner />
  ) : (
    <Fragment>
      <h1 className="large text-primary">Posts</h1>
      <p className="lead">
        <i className="fas fa-user" /> Welcome to the Posts
      </p>
      {/* PostForm */}
      <div className="Posts">
        {posts.map((post) => (
          <PostItem key={post._id} post={post} />
        ))}
      </div>
    </Fragment>
  );
};

Posts.propTypes = {
  getPosts: PropTypes.func.isRequired,
  post: PropTypes.object.isRequired,
};

const mapStateToProps = (state) => ({
  post: state.post,
});

export default connect(mapStateToProps, { getPosts })(Posts);

② PostItemコンポーネントを生成。

以下項目追加。
・写真{avatar}、ユーザ名{name}、投稿日時{date}、投稿内容{text}、likes数{likes.length}、comments数{comments.length}、認証済の場合の削除ボタン{!auth.loading && user ===...}
・ProfileDisplayリンク設置{/profile/${user}}

components/posts/PostItem.js
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import Moment from 'react-moment';
import { connect } from 'react-redux';

const PostItem = ({
  auth,
  post: { _id, text, name, avatar, user, likes, comments, date },
}) => {
  return (
    <div class="post bg-white p-1 my-1">
      <div>
        <Link to={`/profile/${user}`}>
          <img class="round-img" src={avatar} alt="" />
          <h4>{name}</h4>
        </Link>
      </div>
      <div>
        <p class="my-1">{text}</p>
        <p class="post-date">
          Posted on <Moment format="YYY/MM/DD">{date}</Moment>
        </p>
        <button type="button" class="btn btn-light">
          <i class="fas fa-thumbs-up" />{' '}
          {likes.length > 0 && <span>{likes.length}</span>}
        </button>
        <button type="button" class="btn btn-light">
          <i class="fas fa-thumbs-down"></i>
        </button>
        <Link to={`/posts/${_id}`} class="btn btn-primary">
          Discussion{' '}
          {comments.length > 0 && (
            <span class="comment-count">{comments.length}</span>
          )}
        </Link>
              {auth.isAuthenticated === false
                ? null
                : !auth.loading &&
                  user === auth.user._id && (
                    <button
                      onClick={(e) => deletePost(_id)}
                      type="button"
                      className="btn btn-danger"
                    >
                      <i className="fas fa-times"></i>
                    </button>
                  )}
      </div>
    </div>
  );
};

PostItem.propTypes = {
  post: PropTypes.object.isRequired,
  auth: PropTypes.object.isRequired,
};

const mapStateToProps = (state) => ({
  auth: state.auth,
});

export default connect(mapStateToProps, {})(PostItem);

3. Like & Unlike Functionality

① TYPEアクションにUPDATE_LIKES設置
actions/types.js
//...
+ export const UPDATE_LIKES = 'UPDATE_LIKES';

② Likeアクションを設置
・Get postsをコピペしてAddLikeを加工
・api/postsAPIにputしたい(payloadは、postのidとlikes)
・removeLikeはaddLikeとほぼ同じ。

actions/post.js
//...
+ import { GET_POSTS, POST_ERROR, UPDATE_LIKES } from './types';
//...
// 以下、Get postsをコピペして加工
// Add like
export const AddLike = (id) => async (dispatch) => {
  try {
    const res = await axios.put(`/api/posts/like/${id}`);
    dispatch({
      type: UPDATE_LIKES,
      payload: { id, likes: res.data },
    });
  } catch (err) {
    dispatch({
      type: POST_ERROR,
      payload: { msg: err.response.statusText, status: err.response.status },
    });
  }
};

// Remove like
export const removeLike = (id) => async (dispatch) => {
  try {
    const res = await axios.put(`/api/posts/unlike/${id}`);
    dispatch({
      type: UPDATE_LIKES,
      payload: { id, likes: res.data },
    });
  } catch (err) {
    dispatch({
      type: POST_ERROR,
      payload: { msg: err.response.statusText, status: err.response.status },
    });
  }
};

③ UPDATE_LIKE Reducerを設置

・postsを操作する。each post
・postのidとpayloadのid(actionで設置)がマッチしていれば、postとlikesを返す。マッチしていなければ、do nothing(ただpostを返す)

reducers/post.js
+ import { GET_POSTS, POST_ERROR, UPDATE_LIKES } from '../actions/types';
//...以下追加。
    case UPDATE_LIKES:
      return {
        ...state,
        posts: state.posts.map((post) =>
          post._id === payload.id ? { ...post, likes: payload.likes } : post
        ),
        loading: false,
      };
    default:
      return state;
  }
}

④ PostItemコンポーネントにLikeボタン設置

components/posts/PostItem.js
//...
+ import { addLike, removeLike } from '../../actions/post';

const PostItem = ({
+  addLike,
+  removeLike,
  auth,
  post: { _id, text, name, avatar, user, likes, comments, date },
}) => {
  return (
//...
        <button
+          onClick={(e) => addLike(_id)}
          type="button"
          className="btn btn-light"
        >
          <i className="fas fa-thumbs-up" />{' '}
          {likes.length > 0 && <span>{likes.length}</span>}
        </button>
        <button
+          onClick={(e) => removeLike(_id)}
          type="button"
          className="btn btn-light"
        >
          <i className="fas fa-thumbs-down"></i>
        </button>
//...
PostItem.propTypes = {
  post: PropTypes.object.isRequired,
  auth: PropTypes.object.isRequired,
+  addLike: PropTypes.func.isRequired,
+  removeLike: PropTypes.func.isRequired,
};

+ export default connect(mapStateToProps, { addLike, removeLike })(PostItem);

4. Deleting Posts

① DELETE_POSTタイプ

actions/types.js
//...
+ export const DELETE_POST = 'DELETE_POST';

② deletePostアクション設置

・removeLikeアクションをコピペして更新

actions/post.js
//...
+ import { DELETE_POST, GET_POSTS, POST_ERROR, UPDATE_LIKES } from './types';
//...
// Delete post
export const deletePost = (id) => async (dispatch) => {
  try {
    await axios.delete(`/api/posts/${id}`);
    dispatch({
      type: DELETE_POST,
      payload: id,
    });

    dispatch(setAlert('Post Removed', 'success'));
  } catch (err) {
    dispatch({
      type: POST_ERROR,
      payload: { msg: err.response.statusText, status: err.response.status },
    });
  }
};

③ DELETE_POST reducer

reducers/post.js
//..
  UPDATE_LIKES,
+  DELETE_POST,
} from '../actions/types';
//...
+   case DELETE_POST:
+     return {
+       ...state,
+       posts: state.posts.filter((post) => post._id !== payload),
+       loading: false,
+      };
//...

④ Deleteボタン設置。

components/posts/PostItem.js
//...
+ import { addLike, removeLike, deletePost } from '../../actions/post';

const PostItem = ({
  addLike,
  removeLike,
+  deletePost,
  auth,
  post: { _id, text, name, avatar, user, likes, comments, date },
}) => {
  return (
//...
         {!auth.loading && user === auth.user._id && (
           <button
+            onClick={(e) => deletePost(_id)}
             type="button"
             className="btn btn-danger"
           >
             <i className="fas fa-times"></i>
           </button>
         )}
//...
PostItem.propTypes = {
  post: PropTypes.object.isRequired,
  auth: PropTypes.object.isRequired,
  addLike: PropTypes.func.isRequired,
+  removeLike: PropTypes.func.isRequired,
+  deletePost: PropTypes.func.isRequired,
};

const mapStateToProps = (state) => ({
  auth: state.auth,
});

+ export default connect(mapStateToProps, { addLike, removeLike, deletePost })(
  PostItem
);

5. Adding Posts

① ADD_POSTアクションタイプ設置

actions/types.js
//...
+ export const ADD_POST = 'ADD_POST';

② addPostアクション

・deletePostアクションをコピペして編集。

actions/post.js
//...
import {
+  ADD_POST,
  DELETE_POST,
  GET_POSTS,
  POST_ERROR,
  UPDATE_LIKES,
} from './types';
//...
// Add post
export const addPost = (formData) => async (dispatch) => {
  const config = {
    headers: {
      'Content-Type': 'application/json',
    },
  };

  try {
    const res = await axios.post('/api/posts', formData, config);

    dispatch({
      type: ADD_POST,
      payload: res.data,
    });

    dispatch(setAlert('Post Created', 'success'));
  } catch (err) {
    dispatch({
      type: POST_ERROR,
      payload: { msg: err.response.statusText, status: err.response.status },
    });
  }
};

④ ADD_POST Reducer

~~~ 説明 ~~~
posts: [payload(Newポスト), ...state.posts(既存ポスト配列)]
→ 配列の順番もNewポストが一番上に来るようになる。

reducers/post.js
import {
  GET_POSTS,
  POST_ERROR,
  UPDATE_LIKES,
  DELETE_POST,
+  ADD_POST,
} from '../actions/types';
//...
  switch (type) {
//...
+   case ADD_POST:
+     return {
+       ...state,
+       posts: [payload, ...state.posts],
+       loading: false,
+     };
//...

⑤ポストフォームコンポーネントを生成

【前提】 rafcp + htmlテンプレ + Fragment+className以外の追加を + で表記。

components/posts/PostForm.js
+ import React, { Fragment, useState } from 'react';
import PropTypes from 'prop-types';
+ import { connect } from 'react-redux';
+ import { addPost } from '../../actions/post';

+ const PostForm = ({ addPost }) => {
+  const [text, setText] = useState('');

  return (
    <Fragment>
      <div className="post-form">
        <div className="bg-primary p">
          <h3>Say Something...</h3>
        </div>
        <form
          className="form my-1"
+          onSubmit={(e) => {
+            e.preventDefault();
+            addPost({ text });
+            setText('');
+          }}
        >
          <textarea
            name="text"
            cols="30"
            rows="5"
            placeholder="Create a post"
+            value={text}
+            onChange={(e) => setText(e.target.value)}
            required
          ></textarea>
          <input type="submit" className="btn btn-dark my-1" value="Submit" />
        </form>
      </div>
    </Fragment>
  );
};

PostForm.propTypes = {
+  addPost: PropTypes.func.isRequired,
};

+ export default connect(null, { addPost })(PostForm);

⑥Postコンポーネントにを追加。

components/posts/Post.js
//...
import PostItem from './PostItem';
+ import PostForm from './PostForm';
import { getPosts } from '../../actions/post';
//...
  return loading ? (
    <Spinner />
  ) : (
    <Fragment>
      <h1 className="large text-primary">Posts</h1>
      <p className="lead">
        <i className="fas fa-user" /> Welcome to the Posts
      </p>
+      <PostForm />
      <div className="Posts">
        {posts.map((post) => (
          <PostItem key={post._id} post={post} />
        ))}
      </div>
    </Fragment>
  );
};
//...

6. Single Post Display

GET_POSTアクションタイプ

actions/types.js
//...
export const GET_POSTS = 'GET_POSTS';
+ export const GET_POST = 'GET_POST';
//...

① getPost

・getPostsをコピペして一部修正。

actions/post.js
//...
import {
  GET_POSTS,
  POST_ERROR,
  UPDATE_LIKES,
  DELETE_POST,
  ADD_POST,
+  GET_POST,
} from './types';
//...
//以下すべて追加。
// Get post
export const getPost = (id) => async (dispatch) => {
  try {
    const res = await axios.get('/api/posts/${id}');
    dispatch({
      type: GET_POST,
      payload: res.data,
    });
  } catch (err) {
    dispatch({
      type: POST_ERROR,
      payload: { msg: err.response.statusText, status: err.response.status },
    });
  }
};

② GET_POSTリデューサーを設置。

reducers/post.js
import {
  GET_POSTS,
  POST_ERROR,
  UPDATE_LIKES,
  DELETE_POST,
  ADD_POST,
+  GET_POST,
} from '../actions/types';
//...
  switch (type) {
    case GET_POSTS:
      return {
        ...state,
        posts: payload,
        loading: false,
      };
+   case GET_POST:
+     return {
+       ...state,
+       post: payload,
+       loading: false,
+     };

③ Postルート設置。

src/App.js
//...
import Posts from './components/posts/Posts';
+ import Post from './components/post/Post';
//...
          <Route exact path="/posts" component={Posts} />
+         <Route exact path="/posts/:id" component={Post} />
//...

④ PostItemにshowActionsを設置

~~~ 説明 ~~~
showActions: 他のコンポーネントでshowActions内のFragmentは表示させないようにする
・PostItemコンポーネント上では表示させたいので、true設定
・Postコンポーネント上では非表示にしたいので、false設定

components/posts/PostItem.js
//...
const PostItem = ({
  addLike,
  removeLike,
  deletePost,
  auth,
  post: { _id, text, name, avatar, user, likes, comments, date },
+  showActions,
}) => {
  return (
//...
+         {showActions && (
+           <Fragment>
             <button
               onClick={(e) => addLike(_id)}
               type="button"
               className="btn btn-light"
             >
//...
             {!auth.loading && user === auth.user._id && (
               <button
                 onClick={(e) => deletePost(_id)}
                 type="button"
                 className="btn btn-danger"
               >
                 <i className="fas fa-times"></i>
               </button>
             )}
+           </Fragment>
+         )}
        </div>
      </div>
    </Fragment>
  );
};

+ PostItem.defaultProps = {
+  showActions: true,
+ };

PostItem.propTypes = {
  post: PropTypes.object.isRequired,
  auth: PropTypes.object.isRequired,
  addLike: PropTypes.func.isRequired,
  removeLike: PropTypes.func.isRequired,
  deletePost: PropTypes.func.isRequired,
};
//...

⑤ Postコンポーネント

PostItemコンポーネントを持ってくる(しかし、showActionsをfalseにする)

components/post/Post.js
import React, { Fragment, useEffect } from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import { connect } from 'react-redux';
import Spinner from '../layout/Spinner';
import PostItem from '../posts/PostItem';
import { getPost } from '../../actions/post';

const Post = ({ getPost, post: { post, loading }, match }) => {
  useEffect(() => {
    getPost(match.params.id);
  }, [getPost]);

  return loading || post === null ? (
    <Spinner />
  ) : (
    <Fragment>
      <Link to="/posts" className="btn">
        Back To Posts
      </Link>
      <PostItem post={post} showActions={false} />
    </Fragment>
  );
};

Post.propTypes = {
  getPost: PropTypes.func.isRequired,
  post: PropTypes.object.isRequired,
};

const mapStateToProps = (state) => ({
  post: state.post,
});

export default connect(mapStateToProps, { getPost })(Post);

7. Adding Comments

① アクションタイプ設定

actions/types.js
//...
export const ADD_POST = 'ADD_POST';
+ export const ADD_COMMENT = 'ADD_COMMENT';
+ export const REMOVE_COMMENT = 'REMOVE_COMMENT';

② addComment + deleteComment アクションを追加。

・addComment: addPostアクションをコピペして修正。
・deleteComment: addCommentアクションをコピペして修正。

actions/post.js
import {
  GET_POSTS,
  POST_ERROR,
  UPDATE_LIKES,
  DELETE_POST,
  ADD_POST,
  GET_POST,
+  ADD_COMMENT,
+  REMOVE_COMMENT,
} from './types';
//...
//以下全て追加。
// Add comment
export const addComment = (postId, formData) => async (dispatch) => {
  const config = {
    headers: {
      'Content-Type': 'application/json',
    },
  };

  try {
    const res = await axios.post(
      `/api/posts/comment/${postId}`,
      formData,
      config
    );

    dispatch({
      type: ADD_COMMENT,
      payload: res.data,
    });

    dispatch(setAlert('Comment Added', 'success'));
  } catch (err) {
    dispatch({
      type: POST_ERROR,
      payload: { msg: err.response.statusText, status: err.response.status },
    });
  }
};

// Delete comment
export const deleteComment = (postId, commentId) => async (dispatch) => {
  try {
    const res = await axios.delete(`/api/posts/comment/${postId}/${commentId}`);

    dispatch({
      type: REMOVE_COMMENT,
      payload: commentId,
    });

    dispatch(setAlert('Comment Removed', 'success'));
  } catch (err) {
    dispatch({
      type: POST_ERROR,
      payload: { msg: err.response.statusText, status: err.response.status },
    });
  }
};

③ ADD_COMMENT + REMOVE_COMMENT リデューサー設置

comments: payload -> postのすべてのコメントデータを持ってくる。
comments: state.post.comments.filter((comment) => comment._id !== payload) -> 削除したcomment._id以外のすべてのコメントデータを持ってくる。

reducers/post.js
import {
  GET_POSTS,
  POST_ERROR,
  UPDATE_LIKES,
  DELETE_POST,
  ADD_POST,
  GET_POST,
+  ADD_COMMENT,
+  REMOVE_COMMENT,
} from '../actions/types';
//...
    case ADD_COMMENT:
      return {
        ...state,
        post: { ...state.post, comments: payload },
        loading: false,
      };
    case REMOVE_COMMENT:
      return {
        ...state,
        post: {
          ...state.post,
          comments: state.post.comments.filter(
            (comment) => comment._id !== payload
          ),
        },
        loading: false,
      };
    default:
      return state;
  }
}

④ コメントフォームを設置。

・Fragment内は、PostFormからコピペして修正。

components/post/CommentForm.js
import React, { Fragment, useState } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { addComment } from '../../actions/post';

const CommentForm = ({ postId, addComment }) => {
  const [text, setText] = useState('');

  return (
    <Fragment>
      <div className="post-form">
        <div className="bg-primary p">
          <h3>Leave a Comment</h3>
        </div>
        <form
          className="form my-1"
          onSubmit={(e) => {
            e.preventDefault();
            addComment(postId, { text });
            setText('');
          }}
        >
          <textarea
            name="text"
            cols="30"
            rows="5"
            placeholder="Create a post"
            value={text}
            onChange={(e) => setText(e.target.value)}
            required
          ></textarea>
          <input type="submit" className="btn btn-dark my-1" value="Submit" />
        </form>
      </div>
    </Fragment>
  );
};

CommentForm.propTypes = {
  addComment: PropTypes.func.isRequired,
};

export default connect(null, { addComment })(CommentForm);

⑤ PostページにCommentFormを設置。

components/post/Post.js
//...
import PostItem from '../posts/PostItem';
+ import CommentForm from '../post/CommentForm';
import { getPost } from '../../actions/post';
//...
  return loading || post === null ? (
    <Spinner />
  ) : (
    <Fragment>
      <Link to="/posts" className="btn">
        Back To Posts
      </Link>
      <PostItem post={post} showActions={false} />
+      <CommentForm postId={post._id} />
    </Fragment>
  );
};
//...

8. Comment Display & Delete


useIDはcommentから持ってくる。

components/post/CommentItem.js
import React, { Fragment } from 'react';
import { Link } from 'react-router-dom';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import Moment from 'react-moment';
import { deleteComment } from '../../actions/post';

const CommentItem = ({
  postId,
  comment: { _id, text, name, avatar, user, date },
  auth,
  deleteComment,
}) => {
  return (
    <Fragment>
      <div class="post bg-white p-1 my-1">
        <div>
          <Link to={`/profile/${user}`}>
            <img class="round-img" src={avatar} alt="" />
            <h4>{name}</h4>
          </Link>
        </div>
        <div>
          <p class="my-1">{text}</p>
          <p class="post-date">
            Posted on <Moment format="YYY/MM/DD">{date}</Moment>
          </p>
          {!auth.loading && user === auth.user._id && (
            <button
              onClick={(e) => deleteComment(postId, _id)}
              type="button"
              className="btn btn-danger"
            >
              <i className="fas fa-times" />
            </button>
          )}
        </div>
      </div>
    </Fragment>
  );
};

CommentItem.propTypes = {
  postId: PropTypes.string.isRequired,
  comment: PropTypes.object.isRequired,
  auth: PropTypes.object.isRequired,
  deleteComment: PropTypes.func.isRequired,
};

const mapStateToProps = (state) => ({
  auth: state.auth,
});

export default connect(mapStateToProps, { deleteComment })(CommentItem);
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Reactでビデオ要素をキャプチャしてDBに保存する

Reactでビデオ要素をキャプチャしてDBに保存する実装のメモ。

サーバサイドは/api/internal/register_imageのエンドポイントを作ってbinary型のカラムに保存できるようにしておく

フロント側の実装

video要素をcanvasに描画してそのままrequestで送れる。

const sample = () => {
  const videoCapture = () => {
    const canvas = document.getElementById('video-canvas');
    const videoHeight = video.videoHeight;
    const videoWidth = video.videoWidth;

    canvas.height = videoHeight;
    canvas.width = videoWidth;
    canvas.getContext('2d').drawImage(video, 0, 0);
    const canvas_image = canvas.toDataURL('image/png');
    axios.post(
      '/api/internal/register_image',{
        headers: {
          'content-type': 'multipart/form-data',
        },
        image: image,
      }
    );
  }

  return (
    <video></video>
    <canvas id='video-canvas' hidden><canvus>
    <button onClick={videoCapture}>キャプチャする</button>
  )
}

こんな形式の値がpostされる。

data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAoAAAAHgCAYAAAA10dzkAAAgAElEQVR4Xuy9iZOt2VXdue98c8431CghkISMaSOMsY3H6Gg3NgaDcdvR0dER/RfbbcAYDBJSSfVevfll5svxzkPHb6297/0y65WqJAGtwmRFxsvKvPe733DOPuusvfbarfVqvY4vy1frzom+7cz5XTsi+JfXL6/i5vpVrJfnsV5No9XqRKvVisVyEa31Mq7O38T5yat49eyTePH0SQx7/Viv1/o7t6a1jlivV7FuraPX68VyuY7Vahk345sYjUbx7W//evzSL/+92Dk4jGjxgatoxSrWsdTP88U8FouF3sNXuxXR7XRivV7qPFarVbQ7bb231WpHSycdsVwto91uR7fbjU6np4vi7+v1IpbLeazWK72OY7U7Hf3Mefa6Pf3/StdfN4x/W9FucWP8tVrx+rWucbla6WfOh+9O18fk53Z+87Pf6JvO6/Uv3/xtnX9vrfWeTqcfwed1u9Fqc34+B73B7/Lx9E/+nmPynnY7gnuiB9mN1bqle1H/vx0F9T7+lsfP+6fjbL5aES3OIY+xrmO1dWzOvd1uNc9K1/5X/eVPqHuSn8aAa37Vff2rPpmf9vh12s0hd+s6bj+OT39cjQn+Ze7No617sY5VLGOxmsR8NY3ZfByL1SouLm/i//2vfxh/9Ad/HOPRNF6/fh3X1zcxmy2i3xtqXjFX+4Od2N8/1Lwfj2exWC41hJlL3U4vOp1uaNi1WrFcLmI+X8R0Oo35jHm7jm6rp/fyNZlMNHF2d3b1+n6fWBGxXK70nn5/EMvFMqbTmeZ9p9PRXGIuL+dzz3WN44jVgniw1rjr9brRbncUI3gNX/y+xXfOSeZoxZLlchmz2Uyftbe/p/csFkvNOz6Lr8FgoDlMHOt2iR+cJ8ePmM6mGnbElna7q3lR94HX9bpdvZ/jdns9xRXu12q50n2ZjCdxfn6+iRnEnOFgGMP+IHaGg+j2OjFfTmK1WsRitojDw8P4lV/9+/Huu+/G48eP4+TkJK6urmI0HuserloR07mfjeJUuxPtbkd/6/X7Opf9g8PY29+Pfq+va1EsXiz0LPg7r+VrOh7HarXWuXLPBsMd/b2nmLuKyWQco9GNrmExn8V0NtbvHPPnEe0lISu67XX0OxE7g07s9tv
  • 表示側 サーバ側から上記の形式でデータを受け取ってそのままimgタグのsrcに指定して表示できる
  const [images, setImages] = useState([]);

  useEffect(() => {
    async function fetchImages() {
      const images = await axios.get
        '/api/internal/images'
      );
      setImages(images.data);
    }
    fetchImages();
  }, []);

  return (
    images.map((image, i) => (
      <img src={image.image} key={i}</img>
    )
  )
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

React NativeでのModalの作り方

はじめに

現在、React Nativeでアプリを開発しており、いい感じのModal(ボタンを押したら下から画面が出てくるやつ)を作ろうとしたのですが、悪戦苦闘したため、記事に残し備忘録の1つとして残します。

作り方

まずはModal画面に遷移するボタンを作成しましょう。ちなみに、今回はuseStateを使用します。

App.js
export default function App() {
  const [modalOpen, setModalOpen] = useState(false);

  return (
    <View>
      <View>
        <Text style={{ fontSize: 24 }}>Lists</Text>

        <TouchableHighlight
          style={styles.openButton}
          onPress={() => {
            setModalOpen(true);
          }}
        >
          <Text>Add</Text>
        </TouchableHighlight>
      </View>
  );
}

// スタイル

const styles = StyleSheet.create({
  openButton: {
    backgroundColor: "#F194FF",
    borderRadius: 20,
    padding: 10,
    paddingLeft: 20,
    paddingRight: 20,
    elevation: 2,
  },
});

Buttonタグでもいいのですが、ボタンらしいスタイルにしたいので今回はTouchableHighlightを使用しました。以下のスタイルのようになります。
スクリーンショット 2020-09-27 9.09.17.png

Modalを追加

App.js
return (
    <View>

{/* 投稿画面 */}
      <Modal visible={modalOpen} animationType="slide">
        <View style={StyleSheet.modelContent}>
          <Text
            style={{
              fontSize: 30,
              paddingTop: 50,
              marginLeft: "auto",
              marginRight: "auto",
              marginTop: "auto",
              marginBottom: 70,
            }}
          >
            Modal画面
          </Text>
        </View>
      </Modal>

    <View> ~ </View>

    </View>
);

先ほどのコードの上にModalを追加すれば、完成です。Modal画面とボタンがある画面は別のファイルにコード記載した方が良さそうですね。今回は説明のためにあえて一緒に記述させました。以下がModal画面です。スクリーンショット 2020-09-27 5.50.43.png
これが下から出てきます。

あとがき

本当はMaterial Iconを押した時に、Modalが出てくるようにしたいのですが、調べてもよくわからなかったので、自作でボタンを作成して実装しました。分かる方がいましたら、教えて欲しいです。

参考

React Native - Modal

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

GraphQL入門

はじめに

対象読者

  • GraphQLについてざっくり学びたい人
  • 公式ドキュメント読むのがしんどい人
  • Apollo/Relayどっちを使うか迷っている人

GraphQL概論編

RESTful API

代表的なWeb APIである、REST APIについて、簡単にまとめつつ、
GraphQLでできることについて、書いていこうと思います。

RESTful API(REST API)とは、Webシステムを外部から利用するためのプログラムの呼び出し規約(API)の種類の一つで、RESTと呼ばれる設計原則に従って策定されたものです。RESTそのものは適用範囲の広い抽象的なモデルだが、一般的にはRESTの考え方をWeb APIに適用したものをRESTful APIと呼んでいます。

RESTful APIでは、URL/URIですべてのリソースを一意に識別し、クライアントがHTTP(あるいはHTTPS)で要求(リクエスト)を送信すると、応答(レスポンス)がXMLやHTML、JSON、CSV、プレーンテキストなどで返されます。
引用元

とIT用語辞典に書いてあるのですが、かなりざっくりまとめると、
REST APIでは、あるURIにアクセスすると、そのURIに結びつく情報をレスポンスとして返します。
例えば、以下のエンドポイントにリクエストを送信すると、それぞれ固有のレスポンスを返すということです。
例:
/api/food/hot-dog
/api/sport/skiing
/api/city/Tokyo

RESTful APIの特徴

RESTful APIの特徴の一つとして、過剰な取得というものがあります。
説明のためにREST API版のSWAPIを利用し、https://swapi.dev/api/people/1/にGETリクエストを投げてみます。
下記にResponseを記載します。
ですが、もしクライアントが本当に必要だった情報が"name", "height", "mass"だった場合、余分なデータが多いことが分かります。
これが、REST APIの過剰取得という特徴です。ただ、気をつけたいのは、この特徴はデメリットということではなく、あくまでのその使用用途に依存します。なので、通常REST APIを使用する場合は、不必要な情報は使い捨てるような使い方になると思います。

{
    "name": "Luke Skywalker",
    "height": "172",
    "mass": "77",
    "hair_color": "blond",
    "skin_color": "fair",
    "eye_color": "blue",
    "birth_year": "19BBY",
    "gender": "male",
    "homeworld": "http://swapi.dev/api/planets/1/",
    "films": [
        "http://swapi.dev/api/films/1/",
        "http://swapi.dev/api/films/2/",
        "http://swapi.dev/api/films/3/",
        "http://swapi.dev/api/films/6/"
    ],
    "species": [],
    "vehicles": [
        "http://swapi.dev/api/vehicles/14/",
        "http://swapi.dev/api/vehicles/30/"
    ],
    "starships": [
        "http://swapi.dev/api/starships/12/",
        "http://swapi.dev/api/starships/22/"
    ],
    "created": "2014-12-09T13:50:51.644000Z",
    "edited": "2014-12-20T21:17:56.891000Z",
    "url": "http://swapi.dev/api/people/1/"
}

GraphQLって何???

まず、GraphQLを学ぶにあたり、グラフ理論についてざっくり知っているといいので、説明します。

グラフ理論

グラフ理論とは、ノード(頂点)の集合とエッジ(辺)の集合で構成されるグラフに関する学問です。

  

この学問の発祥になったとされるのが、ケーニヒスベルクの橋の問題です。こちらは有名なので、別途調べていただけると面白いと思います。※ここでは、割愛させていただきます。

グラフ理論とGraphQL

グラフ理論とGraphQLの繋がりを分かりやすくする為に、FacebookのようなSNSを考えてみます。

下記の図は、アイアンマンを基点に彼のfacebookで繋がっている友人関係をリクエストした時の繋がりを表しています。
そうするとこのリクエストは、下記の木のような構造になります。
指定したアイアンマンが根で、そのアイアンマンに紐づく友人関係が子になる形です。
このリクエストで、アイアンマンは彼の友人同士とエッジで接続されていることが分かります。
実は、この構造がGraphQLのクエリに似ているのです。

ツリー構造
・person
 - name
 - location
 - birthday
 - friends
   * friend name
   * friend location
   * friend birthday
GraphQLのクエリ
{
  me {
    name
    location
    birthday
    friends {
      name
      location
      birthday
    }
  }
}

GraphQLとは

  • GraphQLとは、 API用のクエリ言語であり、TypeSystemを使用してクエリを実行するためのサーバ側のランタイムです。
  • また、クライアントがサーバからデータを取得、変更、講読ができるようにするためのデータ言語でもあります。

GraphQLの特徴

  1. 型指定されたスキーマ
    ・ フロントエンジニアだけで、開発や対応ができるようになります。 ・ 型指定ができるので、型が堅牢になります。
  2. クライアントからのレスポンス形式の指定
    ・ クライアントからレスポンスの形式を指定できるようになります。 これによりオーバフェッチ、アンダーフェッチがなくなります。
  3. サブスクリプションを利用したリアルタイム処理
    ・ クライアントはデータをサブスクライブすることで、イベントドリブンに処理を実装することが可能になります。

クライアントからのレスポンス形式の指定

クリーンなインターフェースを実現できます。
どういうことかというと、例えば、下図のようにREST APIを利用し、ある1つの画面に表示すべきデータをフェッチするのに、複数のリソースに対しAPIを叩かなければならない場合、URIを複数アクセスする必要があります。
それに比べ、GraphQLの場合ですと、1つのリクエストで複数のリソースに対し問い合わせをする処理をGraphQL側がまとめ処理することができます。
これによりクリーンなインターフェースになるということです。

サブスクリプションを利用したリアルタイム処理 ①

Mutationをトリガーにしたイベントベースでの処理が行えます。
Mutationとは、GraphQLにおけるトランザクション処理(Put/Post/Delete)のことを指しています。
なので、クライアント側でPut/Post/DeleteをGraphQLサーバ側に行った場合、このPut/Post/Deleteの処理をサブスクリプションしているクライアントがいた場合、そのクライアントに対し、行われた処理内容のデータをpushすることができます。

サブスクリプションを利用したリアルタイム処理 ②

実際のGraphQLサーバ側のスキーマとClient側の処理例

サブスクリプションを利用したユースケース

もし、サブスクリプションを使うとした場合、考えられるユースケース

  • チャットアプリ
  • SNS
  • お知らせ等の通知機能
    etc...

GraphQLに対する問題(デメリット)

変更管理
・ データを取得して操作するためにGlaphQL APIの仕様を理解する必要があります。
・ フロントエンドからのクエリを処理する方法を理解する必要があります。
N + 1 問題
「1 つの SQL で N 件のレコードをフェッチしたあと、 それぞれ(フェッチした項目)に対して関連するレコードを個別にフェッチするのに N回 SQL を発行している」 状態です。
これ何が問題かというと、この状態になると、RDB サーバと通信するための時間及びRDBMSがSQLを 解析する時間が増加する恐れが考えられます。
# N個のarticlesをフェッチする(SQLは1つ)

SELECT * FROM articles;

# articles ごとにuserをフェッチする(SQL1つがN回)

SELECT * FROM users WHERE id = 1;
SELECT * FROM users WHERE id = 2;
SELECT * FROM users WHERE id = 3;
SELECT * FROM users WHERE id = 4;
SELECT * FROM users WHERE id = 5;
SELECT * FROM users WHERE id = 6;
・
・
・
Client側のキャッシュ戦略
GraphQLは単一エンドポイントなので、HTTPのCache-ControllのようなURLベースのキャッシュ機構やCDNでのキャッシュ(reduxなど)はそのままでは使用ができません。

N + 1 問題

解決策
N 回繰り返している SQL を 1 つにまとめる必要があります。
→ 遅延読み込み(Lazy Loading): 必要なデータが分かったあとで読み込み実施します。
遅延評価
必要なデータを一通り宣言したあとで、それらをまとめて一括で解決する。
Resolver(スキーマによる名前解決)が promise を返した場合、その promise が fullfilled になるまで GraphQL は後続の評価を待つようにする。

Client側のキャッシュ戦略

ネットワークキャッシング
リクエストを傍受し、アプリケーションサーバーにアクセスする代わりに、メモリから直接応答を返します。
→ CDNでのキャッシュ(reduxなど)
解決策
Client側における実装で、キャッシュを管理できるライブラリを使用します。
詳しくはGraphQL実装編で説明します。

GraphQL実装編

今回取り扱う実装パターン

サーバサイド
Express + Apollo server
フロントエンド
1. React + Apollo client
2. React + Relay

Express, Apolloとは

Express
Node.js Web アプリケーションのフレームワークです。
Apollo Pratform
GraphQLのためのデータグラフの構築、クエリ、および管理を支援するライブラリです。
データグラフとは、アプリケーションクライアントとバックエンドサービスの間に位置し、 それらの間のデータの流れを指します。

Apollo Pratform図の例

導入方法

前提条件
Node.js v8.x 以上
npm v6.x 以上
git v2.14.1 以上
Apollo Serverの複数の使用方法
1. サーバーレス環境を含むスタンドアロンのGraphQLサーバーです。
2. 既存のNode.jsミドルウェア(Expressなど)へのアドオンです。
→ Apolloプラットフォームは、段階的な採用を推奨している為です。
3. 複数のサービスにおける単一のデータグラフを利用するためのゲートウェイとしての使用です。

Apollo Serverによる実装例

依存関係のあるライブラリをinstall
[apollo-server] : Apollo Server自体のコアライブラリです。
[graphql] : GraphQLスキーマを構築し、それに対してクエリを実行するために使用されるライブラリです。



GraphQLスキーマを定義

クライアントがクエリできるデータの構造を定義します。

クライアントは、 [books]というクエリを実行でき、サーバーは0個以上[Book]の配列を返します。

const { ApolloServer, gql } = require('apollo-server');

const typeDefs = gql`

      type Book {
          title: String
          author: String
      }

      type Query {
           book: [Book]
      }
`;
データセットの定義
上でデータ構造を定義したので、ここではデータ自体を定義します。
接続する任意のソース(データベース、REST API、または別のGraphQLサーバー等)からデータをフェッチできます。
ここでは、サンプルデータをハードコードします。
const books = [
  {
    title: 'xxxxxxxxxxxxxxx',
    author: 'yyyyyyyyyyyyyy',
  },
  {
    title: 'xxxxxxxxxxx2',
    author: 'yyyyyyyyyy2',
  },
];
Resolverの定義
Apolloサーバーはクエリの実行時にそのデータセットを使用する必要があることを認識していない。そのために、Resolverを作成します。
Resolverは、特定の[type]に関連付けられたデータをフェッチする方法をApolloサーバーに伝えます。
const resolvers = {
  Query: {
    books: () => books,
  },
};
Apollo-Severのインスタンスを作成
初期化時に、[スキーマ], [データセット], [Resolver]をApolloサーバーに提供します。
const server = new ApolloServer({ typeDefs, resolvers  });

server.listen().then(({ url }) => {
  console.log(`? Server ready at ${url}`);
});

GraphQL Playgroundツールを利用してクエリを実行
筆者の都合により、レスポンス部分を隠しているのはご容赦ください。(個人情報のため。。)

Client側の実装例

  • React + Apollo client
  • React + Relay

Apollo Client / Relay

  • Apollo clientは、キャッシュ機能と状態管理機能を備えたJavaScript GraphQLクライアントです。
  • Relayは、GraphQLを利用したReactアプリケーションを構築するためのJavaScriptフレームワークです。

Apollo Client VS Relay

  • [Client側のキャッシュ戦略]に依存すると思っています。

Apollo Clientにおけるキャッシュと状態管理

  • Apollo Clientには、ローカルデータをリモートデータと一緒にApolloキャッシュ内に保存できるローカル状態処理機能が組み込まれています。

Relayにおけるキャッシュと状態管理

  • Reduxを用いての状態管理となります。

まとめ

  • 実はApollo以外の選択肢として、AWS Appsyncを利用する方法もある。こちらについては、別記事としていずれ書こうと思います。
  • Apolloを利用する場合のメリットは、Expressを利用したWeb APIを既に作成しており、プロジェクトの都合等でGraphQLを利用する場合、Apolloをアドオンすることができ、そうした場合、徐々にGraphQLへ移行することができる。

参考

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

GraphQL入門-初心者による初心者のためのGraphQL-

はじめに

対象読者

  • GraphQLについてざっくり学びたい人
  • 公式ドキュメント読むのがしんどい人
  • Apollo/Relayどっちを使うか迷っている人

GraphQL概論編

RESTful API

代表的なWeb APIである、REST APIについて、簡単にまとめつつ、
GraphQLでできることについて、書いていこうと思います。

RESTful API(REST API)とは、Webシステムを外部から利用するためのプログラムの呼び出し規約(API)の種類の一つで、RESTと呼ばれる設計原則に従って策定されたものです。RESTそのものは適用範囲の広い抽象的なモデルだが、一般的にはRESTの考え方をWeb APIに適用したものをRESTful APIと呼んでいます。

RESTful APIでは、URL/URIですべてのリソースを一意に識別し、クライアントがHTTP(あるいはHTTPS)で要求(リクエスト)を送信すると、応答(レスポンス)がXMLやHTML、JSON、CSV、プレーンテキストなどで返されます。
[引用元]:http://e-words.jp/w/RESTful_API.html

とIT用語辞典に書いてあるのですが、かなりざっくりまとめると、
REST APIでは、あるURIにアクセスすると、そのURIに結びつく情報をレスポンスとして返します。
例えば、以下のエンドポイントにリクエストを送信すると、それぞれ固有のレスポンスを返すということです。
例:
/api/food/hot-dog
/api/sport/skiing
/api/city/Tokyo

RESTful APIの特徴

RESTful APIの特徴の一つとして、過剰な取得というものがあります。
説明のためにREST API版のSWAPIを利用し、https://swapi.dev/api/people/1/にGETリクエストを投げてみます。
下記にResponseを記載します。
ですが、もしクライアントが本当に必要だった情報が"name", "height", "mass"だった場合、余分なデータが多いことが分かります。
これが、REST APIの過剰取得という特徴です。ただ、気をつけたいのは、この特徴はデメリットということではなく、あくまでのその使用用途に依存します。なので、通常REST APIを使用する場合は、不必要な情報は使い捨てるような使い方になると思います。

{
    "name": "Luke Skywalker",
    "height": "172",
    "mass": "77",
    "hair_color": "blond",
    "skin_color": "fair",
    "eye_color": "blue",
    "birth_year": "19BBY",
    "gender": "male",
    "homeworld": "http://swapi.dev/api/planets/1/",
    "films": [
        "http://swapi.dev/api/films/1/",
        "http://swapi.dev/api/films/2/",
        "http://swapi.dev/api/films/3/",
        "http://swapi.dev/api/films/6/"
    ],
    "species": [],
    "vehicles": [
        "http://swapi.dev/api/vehicles/14/",
        "http://swapi.dev/api/vehicles/30/"
    ],
    "starships": [
        "http://swapi.dev/api/starships/12/",
        "http://swapi.dev/api/starships/22/"
    ],
    "created": "2014-12-09T13:50:51.644000Z",
    "edited": "2014-12-20T21:17:56.891000Z",
    "url": "http://swapi.dev/api/people/1/"
}

GraphQLって何???

まず、GraphQLを学ぶにあたり、グラフ理論についてざっくり知っているといいので、説明します。

グラフ理論

グラフ理論とは、ノード(頂点)の集合とエッジ(辺)の集合で構成されるグラフに関する学問です。

  

この学問の発祥になったとされるのが、ケーニヒスベルクの橋の問題です。こちらは有名なので、別途調べていただけると面白いと思います。※ここでは、割愛させていただきます。

グラフ理論とGraphQL

グラフ理論とGraphQLの繋がりを分かりやすくする為に、FacebookのようなSNSを考えてみます。

下記の図は、アイアンマンを基点に彼のfacebookで繋がっている友人関係をリクエストした時の繋がりを表しています。
そうするとこのリクエストは、下記の木のような構造になります。
指定したアイアンマンが根で、そのアイアンマンに紐づく友人関係が子になる形です。
このリクエストで、アイアンマンは彼の友人同士とエッジで接続されていることが分かります。
実は、この構造がGraphQLのクエリに似ているのです。

ツリー構造
・person
 - name
 - location
 - birthday
 - friends
   * friend name
   * friend location
   * friend birthday
GraphQLのクエリ
{
  me {
    name
    location
    birthday
    friends {
      name
      location
      birthday
    }
  }
}

GraphQLとは

  • GraphQLとは、 API用のクエリ言語であり、TypeSystemを使用してクエリを実行するためのサーバ側のランタイムです。
  • また、クライアントがサーバからデータを取得、変更、講読ができるようにするためのデータ言語でもあります。

GraphQLの特徴

  1. 型指定されたスキーマ
    ・ フロントエンジニアだけで、開発や対応ができるようになります。 ・ 型指定ができるので、型が堅牢になります。
  2. クライアントからのレスポンス形式の指定
    ・ クライアントからレスポンスの形式を指定できるようになります。 これによりオーバフェッチ、アンダーフェッチがなくなります。
  3. サブスクリプションを利用したリアルタイム処理
    ・ クライアントはデータをサブスクライブすることで、イベントドリブンに処理を実装することが可能になります。

クライアントからのレスポンス形式の指定

クリーンなインターフェースを実現できます。
どういうことかというと、例えば、下図のようにREST APIを利用し、ある1つの画面に表示すべきデータをフェッチするのに、複数のリソースに対しAPIを叩かなければならない場合、URIを複数アクセスする必要があります。
それに比べ、GraphQLの場合ですと、1つのリクエストで複数のリソースに対し問い合わせをする処理をGraphQL側がまとめ処理することができます。
これによりクリーンなインターフェースになるということです。

サブスクリプションを利用したリアルタイム処理 ①

Mutationをトリガーにしたイベントベースでの処理が行えます。
Mutationとは、GraphQLにおけるトランザクション処理(Put/Post/Delete)のことを指しています。
なので、クライアント側でPut/Post/DeleteをGraphQLサーバ側に行った場合、このPut/Post/Deleteの処理をサブスクリプションしているクライアントがいた場合、そのクライアントに対し、行われた処理内容のデータをpushすることができます。

サブスクリプションを利用したリアルタイム処理 ②

実際のGraphQLサーバ側のスキーマとClient側の処理例

サブスクリプションを利用したユースケース

もし、サブスクリプションを使うとした場合、考えられるユースケース

  • チャットアプリ
  • SNS
  • お知らせ等の通知機能
    etc...

GraphQLに対する問題(デメリット)

変更管理
・ データを取得して操作するためにGlaphQL APIの仕様を理解する必要があります。
・ フロントエンドからのクエリを処理する方法を理解する必要があります。
N + 1 問題
「1 つの SQL で N 件のレコードをフェッチしたあと、 それぞれ(フェッチした項目)に対して関連するレコードを個別にフェッチするのに N回 SQL を発行している」 状態です。
これ何が問題かというと、この状態になると、RDB サーバと通信するための時間及びRDBMSがSQLを 解析する時間が増加する恐れが考えられます。
# N個のarticlesをフェッチする(SQLは1つ)

SELECT * FROM articles;

# articles ごとにuserをフェッチする(SQL1つがN回)

SELECT * FROM users WHERE id = 1;
SELECT * FROM users WHERE id = 2;
SELECT * FROM users WHERE id = 3;
SELECT * FROM users WHERE id = 4;
SELECT * FROM users WHERE id = 5;
SELECT * FROM users WHERE id = 6;
・
・
・
Client側のキャッシュ戦略
GraphQLは単一エンドポイントなので、HTTPのCache-ControllのようなURLベースのキャッシュ機構やCDNでのキャッシュ(reduxなど)はそのままでは使用ができません。

N + 1 問題

解決策
N 回繰り返している SQL を 1 つにまとめる必要があります。
→ 遅延読み込み(Lazy Loading): 必要なデータが分かったあとで読み込み実施します。
遅延評価
必要なデータを一通り宣言したあとで、それらをまとめて一括で解決する。
Resolver(スキーマによる名前解決)が promise を返した場合、その promise が fullfilled になるまで GraphQL は後続の評価を待つようにする。

Client側のキャッシュ戦略

ネットワークキャッシング
リクエストを傍受し、アプリケーションサーバーにアクセスする代わりに、メモリから直接応答を返します。
→ CDNでのキャッシュ(reduxなど)
解決策
Client側における実装で、キャッシュを管理できるライブラリを使用します。
詳しくはGraphQL実装編で説明します。

GraphQL実装編

今回取り扱う実装パターン

サーバサイド
Express + Apollo server
フロントエンド
1. React + Apollo client
2. React + Relay

Express, Apolloとは

Express
Node.js Web アプリケーションのフレームワークです。
Apollo Pratform
GraphQLのためのデータグラフの構築、クエリ、および管理を支援するライブラリです。
データグラフとは、アプリケーションクライアントとバックエンドサービスの間に位置し、 それらの間のデータの流れを指します。

Apollo Pratform図の例

導入方法

前提条件
Node.js v8.x 以上
npm v6.x 以上
git v2.14.1 以上
Apollo Serverの複数の使用方法
1. サーバーレス環境を含むスタンドアロンのGraphQLサーバーです。
2. 既存のNode.jsミドルウェア(Expressなど)へのアドオンです。
→ Apolloプラットフォームは、段階的な採用を推奨している為です。
3. 複数のサービスにおける単一のデータグラフを利用するためのゲートウェイとしての使用です。

Apollo Serverによる実装例

依存関係のあるライブラリをinstall
[apollo-server] : Apollo Server自体のコアライブラリです。
[graphql] : GraphQLスキーマを構築し、それに対してクエリを実行するために使用されるライブラリです。



GraphQLスキーマを定義

クライアントがクエリできるデータの構造を定義します。

クライアントは、 [books]というクエリを実行でき、サーバーは0個以上[Book]の配列を返します。

const { ApolloServer, gql } = require('apollo-server');

const typeDefs = gql`

      type Book {
          title: String
          author: String
      }

      type Query {
           book: [Book]
      }
`;
データセットの定義
上でデータ構造を定義したので、ここではデータ自体を定義します。
接続する任意のソース(データベース、REST API、または別のGraphQLサーバー等)からデータをフェッチできます。
ここでは、サンプルデータをハードコードします。
const books = [
  {
    title: 'xxxxxxxxxxxxxxx',
    author: 'yyyyyyyyyyyyyy',
  },
  {
    title: 'xxxxxxxxxxx2',
    author: 'yyyyyyyyyy2',
  },
];
Resolverの定義
Apolloサーバーはクエリの実行時にそのデータセットを使用する必要があることを認識していない。そのために、Resolverを作成します。
Resolverは、特定の[type]に関連付けられたデータをフェッチする方法をApolloサーバーに伝えます。
const resolvers = {
  Query: {
    books: () => books,
  },
};
Apollo-Severのインスタンスを作成
初期化時に、[スキーマ], [データセット], [Resolver]をApolloサーバーに提供します。
const server = new ApolloServer({ typeDefs, resolvers  });

server.listen().then(({ url }) => {
  console.log(`? Server ready at ${url}`);
});

GraphQL Playgroundツールを利用してクエリを実行
筆者の都合により、レスポンス部分を隠しているのはご容赦ください。(個人情報のため。。)

Client側の実装例

  • React + Apollo client
  • React + Relay

Apollo Client / Relay

  • Apollo clientは、キャッシュ機能と状態管理機能を備えたJavaScript GraphQLクライアントです。
  • Relayは、GraphQLを利用したReactアプリケーションを構築するためのJavaScriptフレームワークです。

Apollo Client VS Relay

  • [Client側のキャッシュ戦略]に依存すると思っています。

Apollo Clientにおけるキャッシュと状態管理

  • Apollo Clientには、ローカルデータをリモートデータと一緒にApolloキャッシュ内に保存できるローカル状態処理機能が組み込まれています。

Relayにおけるキャッシュと状態管理

  • Reduxを用いての状態管理となります。

まとめ

  • 実はApollo以外の選択肢として、AWS Appsyncを利用する方法もある。こちらについては、別記事としていずれ書こうと思います。
  • Apolloを利用する場合のメリットは、Expressを利用したWeb APIを既に作成しており、プロジェクトの都合等でGraphQLを利用する場合、Apolloをアドオンすることができ、そうした場合、徐々にGraphQLへ移行することができる。

参考

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[MERN⑨] Profile Display

~~~~~~~~~~ (Contents) MERN ~~~~~~~~~~~
[MERN①] Express & MongoDB Setup
https://qiita.com/niyomong/private/3281af84486876f897f7
[MERN②]User API Routes & JWT Authentication
https://qiita.com/niyomong/private/c11616ff7b64925f9a2b
[MERN③] Profile API Routes
https://qiita.com/niyomong/private/8cff4e6fa0e81b92cb49
[MERN④] Post API
https://qiita.com/niyomong/private/3ce66f15375ad04b8989
[MERN⑤] Getting Started With React & The Frontend
https://qiita.com/niyomong/private/a5759e2fb89c9f222b6b
[MERN⑥] Redux Setup & Alerts
https://qiita.com/niyomong/private/074c27259924c7fd306b
[MERN⑦] React User Authentication
https://qiita.com/niyomong/private/37151784671eff3b92b6
[MERN⑧] Dashboard & Profile Management
https://qiita.com/niyomong/private/ab7e5da1b1983a226aca
[MERN⑨] Profile Display
https://qiita.com/niyomong/private/42426135e959c7844dcb
[MERN⑩] Posts & Comments
https://qiita.com/niyomong/private/19c78aea482b734c3cf5
[MERN11] デプロイ
https://qiita.com/niyomong/private/150f9000ce51548134ad
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

1. Finish Profile Actions & Reducer

アクションタイプ

actions/types.js
//...
export const GET_PROFILE = 'GET_PROFILE';
+ export const GET_PROFILES = 'GET_PROFILES';

Profileアクション

actions/profile.js
import {
  CLEAR_PROFILE,
  GET_PROFILE,
  PROFILE_ERROR,
  ACCOUNT_DELETED,
+  GET_PROFILES,
} from './types';
//...
//以下追加。
// Get all profiles
export const getProfiles = () => async (dispatch) => {
  dispatch({ type: CLEAR_PROFILE });
  try {
    const res = await axios.get('/api/profile');

    dispatch({
      type: GET_PROFILES,
      payload: res.data,
    });
  } catch (err) {
    dispatch({
      type: PROFILE_ERROR,
      payload: { msg: err.response.statusText, status: err.response.status },
    });
  }
};
//...

profileリデューサー

reducers/profile.js
//...
  case GET_PROFILES:
    return { 
      ...state,  
      profiles: payload,
      loading: false,
    };
//...

getProfileByIdアクション

actions/profile.js
// Get profile by ID
export const getProfileById = (userId) => async (dispatch) => {
  dispatch({ type: CLEAR_PROFILE });
  try {
    const res = await axios.get(`/api/profile/user/${userId}`);

    dispatch({
      type: GET_PROFILE,
      payload: res.data,
    });
  } catch (err) {
    dispatch({
      type: PROFILE_ERROR,
      payload: { msg: err.response.statusText, status: err.response.status },
    });
  }
};
//...

2. Display Profiles

① components/profilesフォルダ作成

・Profilesコンポーネント生成 (Profilesページ)
・ProfileItemコンポーネント生成 (Profilesの一部)

② ProfilesページをルートとNavbarにを設置。

src/App.js
//...
import EditProfile from './components/profile-forms/EditProfile';
+ import Profiles from './components/profiles/Profiles';
//...
         <Switch>
           <Route exact path="/register" component={Register} />
           <Route exact path="/login" component={Login} />
+          <Route exact path="/profiles" component={Profiles} />
//...
components/layout/Navbar.js
//...
const Navbar = ({ auth: { isAuthenticated, loading }, logout }) => {
  const authLinks = (
    <ul>
+      <li>
+        <Link to="/profiles">Users</Link>
+      </li>
      <li>
        <Link to="/dashboard">
          <i className="fas fa-user" />
          <span className="hide-sm">Dashboard</span>
        </Link>
//...
  const guestLinks = (
    <ul>
+      <li>
+        <Link to="/profiles">Users</Link>
+      </li>
      <li>
        <Link to="/register">Register</Link>
      </li>
//...

③ Profilesコンポーネント(Profilesページ)

components/profiles/Profiles.js
import React, { Fragment, useEffect } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import Spinner from '../layout/Spinner';
import ProfileItem from './ProfileItem';
import { getProfiles } from '../../actions/profile';

const Profiles = ({ getProfiles, profile: { profiles, loading } }) => {
  useEffect(() => {
    getProfiles();
  }, [getProfiles]);

  return (
    <Fragment>
      {loading ? (
        <Spinner />
      ) : (
        <Fragment>
          <h1 className="large text-primary">Developers</h1>
          <p className="lead">
            <i className="fab fa-connectdevelop" /> Browse and connect with
            developers
          </p>
          <div className="profiles">
            {profiles.length > 0 ? (
              profiles.map((profile) => (
                <ProfileItem key={profile._id} profile={profile} />
              ))
            ) : (
              <h4>No profiles found...</h4>
            )}
          </div>
        </Fragment>
      )}
    </Fragment>
  );
};

Profiles.propTypes = {
  getProfiles: PropTypes.func.isRequired,
  profile: PropTypes.object.isRequired,
};

const mapStateToProps = (state) => ({
  profile: state.profile,
});

export default connect(mapStateToProps, { getProfiles })(Profiles);

④ ProfileItemコンポーネント (Profilesの一部)

components/profiles/ProfileItem.js
import React, { Fragment } from 'react';
import { Link } from 'react-router-dom';
import PropTypes from 'prop-types';

const ProfileItem = ({
  profile: {
    user: { _id, name, avatar },
    status,
    skills,
  },
}) => {
  return (
    <Fragment>
      <div className="profile bg-light">
        <img src={avatar} alt="" className="round-img" />
        <div>
          <h2>{name}</h2>
          <ul>
            {status.slice(0, 2).map((status, index) => (
              <li key={index}>{status}</li>
            ))}
          </ul>
          <Link to={`/profile/${_id}`} className="btn btn-primary">
            View Profile
          </Link>
        </div>
        <ul>
          {skills.slice(0, 4).map((skill, index) => (
            <li key={index} className="text-primary">
              <i className="fas fa-check" /> {skill}
            </li>
          ))}
        </ul>
      </div>
    </Fragment>
  );
};

ProfileItem.propTypes = {
  profile: PropTypes.object.isRequired,
};

export default ProfileItem;

3. プロフィールページ

① profileフォルダ生成 -> Profileコンポーネント生成。

② Profileページのルートを設置。

src/App.js
//...
import Profiles from './components/profiles/Profiles';
import Profile from './components/profile/Profile';
//...
           <Route exact path="/profiles" component={Profiles} />
+          <Route exact path="/profile/:id" component={Profile} />
//...

③ Profileコンポーネント(Profileページ)

match: IDとマッチしてるか検証

components/profile/Profile.js
import React, { Fragment, useEffect } from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import { connect } from 'react-redux';
import Spinner from '../layout/Spinner';
import ProfileTop from './ProfileTop';
import ProfileAbout from './ProfileAbout';
import { getProfileById } from '../../actions/profile';

const Profile = ({
  getProfileById,
  profile: { profile, loading },
  auth,
  match,
}) => {
  useEffect(() => {
    getProfileById(match.params.id);
  }, [getProfileById, match.params.id]);

  return (
    <Fragment>
      {profile === null || loading ? (
        <Spinner />
      ) : (
        <Fragment>
          <Link to="/profiles" className="btn btn-light">
            Back To Profiles
          </Link>
          {auth.isAuthenticated &&
            auth.loading === false &&
            auth.user._id === profile.user._id && (
              <Link to="/edit-profile" className="btn btn-dark">
                Edit Profile
              </Link>
            )}
          <div class="profile-grid my-1">
            <ProfileTop profile={profile} />
            <ProfileAbout profile={profile} />
          </div>
        </Fragment>
      )}
    </Fragment>
  );
};

Profile.propTypes = {
  getProfileById: PropTypes.func.isRequired,
  profile: PropTypes.object.isRequired,
  auth: PropTypes.object.isRequired,
};

const mapStateToProps = (state) => ({
  profile: state.profile,
  auth: state.auth,
});

export default connect(mapStateToProps, { getProfileById })(Profile);

④ ProfileTopコンポーネント(Profileページの一部)

components/profile/ProfileTop.js
import React from 'react';
import PropTypes from 'prop-types';

const ProfileTop = ({
  profile: {
    status,
    website,
    social,
    user: { name, avatar },
  },
}) => {
  return (
    <div class="profile-top bg-primary p-2">
      <img class="round-img my-1" src={avatar} alt="" />
      <h1 class="large">{name}</h1>
      <p class="lead">{status}</p>
      <div class="icons my-1">
        {website && (
          <a href={website} target="_blank" rel="noopener noreferrer">
            <i class="fas fa-globe fa-2x" />
          </a>
        )}
        {social && social.twitter && (
          <a href={social.twitter} target="_blank" rel="noopener noreferrer">
            <i class="fab fa-twitter fa-2x"></i>
          </a>
        )}
        {social && social.facebook && (
          <a href={social.facebook} target="_blank" rel="noopener noreferrer">
            <i class="fab fa-facebook fa-2x"></i>
          </a>
        )}
        {social && social.linkedin && (
          <a href={social.linkedin} target="_blank" rel="noopener noreferrer">
            <i class="fab fa-linkedin fa-2x"></i>
          </a>
        )}
        {social && social.youtube && (
          <a href={social.youtube} target="_blank" rel="noopener noreferrer">
            <i class="fab fa-youtube fa-2x"></i>
          </a>
        )}
      </div>
    </div>
  );
};

ProfileTop.propTypes = {
  profile: PropTypes.object.isRequired,
};

export default ProfileTop;

⑤ ProfileAboutコンポーネント(Profileページの一部)

components/profile/ProfileAbout.js
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';

const ProfileAbout = ({
  profile: {
    bio,
    skills,
    user: { name },
  },
}) => {
  return (
    <div class="profile-about bg-light p-2">
      {bio && (
        <Fragment>
          <h2 class="text-primary">{name}'s Bio</h2>
          <p>{bio}</p>
          <div class="line"></div>
        </Fragment>
      )}

      <h2 class="text-primary">Skill Set</h2>
      <div class="skills">
        {skills.map((skill, index) => (
          <div key={index} className="p-1">
            <i className="fas fa-check" /> {skill}
          </div>
        ))}
      </div>
    </div>
  );
};

ProfileAbout.propTypes = {
  profile: PropTypes.object.isRequired,
};

export default ProfileAbout;
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む