【React.js】第三回 Reactでアプリ開発! 〜APIを使用した実践的なアプリを開発するぜ編〜【Redux × TypeScript × Stylus】

JavaScript
マイケル
マイケル
みなさんこんにちは!
マイケルです!
エレキベア
エレキベア
こんにちクマ〜〜
マイケル
マイケル
今日は前回に引き続きReactアプリ開発!
これまで勉強してきたRedux、TypeScriptといった知識を生かしながら、
APIを使用した実践的なReactアプリを作ってみるよ!
エレキベア
エレキベア
ついにちゃんとしたのが開発できるクマね
マイケル
マイケル
具体的には下記のように、WordPress記事をAPI経由で取得して表示する
アプリを作ります!
01 WP APP
↑選択したカテゴリーの記事を表示するアプリ
エレキベア
エレキベア
(このブログクマ・・・。)
こんなの簡単クマよ〜〜〜
マイケル
マイケル
このブログの記事はREST APIで取得できるようになってるから使ってみたよ!
簡単そうに見えるけどこれだけでも結構手間なんだぜ・・・。
エレキベア
エレキベア
とりあえず早く教えるクマ
マイケル
マイケル
それじゃ始めていこう!
ソースコードについてはこれまでと同様、GitHubに上げているので参考に使ってください!

[対象フォルダ]
masarito617/react-study – GitHub
 - 06_wp-app-react-ts

エレキベア
エレキベア
クマ〜〜〜〜
スポンサーリンク

参考書籍

マイケル
マイケル
参考書籍についてはこれまでと同じく以下の2冊になります!
更に知識を深めたい方は是非読んでみてください!

React入門 React・Reduxの導入からサーバサイドレンダリングによるUXの向上まで (NEXT ONE)

実践TypeScript

エレキベア
エレキベア
一読の価値はありクマ〜〜〜(読んでない)

使用するAPI

マイケル
マイケル
まず使用するAPIについてですが、JetPackが公開しているJSON API経由を使用します。
JSON APIではWordPress.com REST APIを経由して記事の情報を取得します!

JSON API – Jetpack by WordPress.com

WordPress.com REST API

マイケル
マイケル
この中で、今回使用するリクエストは以下になります!
  • カテゴリ取得
  • CATEGORIES – WordPress REST API

    https://public-api.wordpress.com/rest/v1.1/sites/$site/categories
  • 記事データ取得
  • POSTS – WordPress REST API

    https://public-api.wordpress.com/rest/v1.1/sites/$site/posts/
  • 記事データ取得(カテゴリ指定時)
  • https://public-api.wordpress.com/rest/v1.1/sites/$site/posts?category=【カテゴリースラッグ】
マイケル
マイケル
$siteにはWordPressのサイトドメイン名が入ります!
今回はこのブログを使うため、elekibear.comを指定します。
エレキベア
エレキベア
こんなブログなので是非勉強に使ってくれクマ

全体の構成

マイケル
マイケル
ざっと全体の作りについて説明します!
前回と同様、React、TypeScript環境を構築し、
下記のように大きく2つのコンポーネントに分けて作りました。
ScreenShot 2021 07 25 14 05 26 2
↑コンポーネントの分け方(Categories、ArticleList)
マイケル
マイケル
Categoriesではカテゴリー一覧を取得して表示
ArticleListでは記事一覧を取得して表示します。
また、カテゴリーが押下されたら記事を再取得するといった仕様になります。
エレキベア
エレキベア
シンプルな構成クマ〜〜

フォルダ構成

マイケル
マイケル
フォルダの構成は下記のようになっています。
こちらもReduxアーキテクチャを適用しており、前回とほとんど同じです。
src
├── dist
│   └── index.html
├── styl
│   └── index.styl
└── ts
    ├── actions
    │   ├── ActionTypes.tsx
    │   ├── ArticleList.tsx
    │   └── Categories.tsx
    ├── components
    │   ├── ArticleList.tsx
    │   └── Categories.tsx
    ├── reducers
    │   ├── ArticleList.tsx
    │   └── Categories.tsx
    ├── types
    │   └── types.d.tsx
    ├── App.tsx
    └── index.tsx
↑フォルダ構成
エレキベア
エレキベア
index.styltypes.tsxは今回始めて出てきたクマね
マイケル
マイケル
よく気づいたね!
その部分は前回とは少し違うから、解説しておこう!

前回との相違点

Stylusを使ったUI構築
マイケル
マイケル
まずはindex.stylについて!
こちらはStylusというのを使ってスタイルを記述しています。

Stylus公式サイト

マイケル
マイケル
CSSメタ言語と呼ばれるものの1つで、TypeScript等と同様
ビルド時にCSSファイルに変換されます。
Stylusを使用すると{}等を省略できたり、階層構造で記述することができるといったメリットがあります!

ScreenShot 2021 07 27 0 29 52
↑CSSに変換される

body
  background-color #333333
  color white
  font-family: "Roboto", "Helvetica", "Arial", sans-serif;
  margin 0px

/** ヘッダー */
.app-bar
  background-color #222222
  top 0px
  height 80px
  border 1px solid #666666
.title
  color #FFFF33
  font-size 34px
  font-weight bold
  padding 3px 12px
  line-height 80px

/** カテゴリー */
.category
  position absolute
  top 80px
  display flex
  justify-content flex-start
  height 35px
  font-size 16px
  margin-top 12px
  margin-left 8px
  line-height 30px
  &-border
    color lightgray
    font-weight lighter
  &-item
    height 34px
    margin auto 1px
    background-color #222222
    color #CCCCFF
    text-align center
    padding 3px 16px 0px 16px
    border 1px solid #666666
    border-bottom 1px solid #666666
    border-radius 5px 5px 0px 0px
    z-index 1
    &:disabled
      background-color #333333
      border-bottom none
    &:hover
      background-color #333333

/** 記事一覧 */
.article
  &-area
    position absolute
    top 126px
    width 100%
    border-top 1px solid #666666
    display flex
    flex-wrap wrap
    justify-content flex-start
  &-count
    width 100%
    margin-top 15px
    margin-left 15px
  &-card
    display flex
    flex-flow column
    align-items center
    background-color white
    color black
    width 300px
    height 320px
    margin 12px
    border-radius 5px
    cursor pointer
    &-image
      object-fit cover
      width 280px
      height 170px
      margin-top 10px
    &-title
      font-size 14px
      font-weight bold
      overflow hidden
      width 280px
      height 65px
      margin-top 8px
    &-category
      display flex
      flex-wrap wrap
      align-items center
      width 280px
      height 28px
      margin-top 8px
      overflow hidden
      &-item
        background-color #333333
        color white
        font-size 8px
        padding 6px 10px
        border-radius 12px
        margin 0px 6px 3px 0px
    &-date
      font-size 12px
      width 280px
      text-align right
      margin-top 8px

/** ローディング */
.load-msg
  margin 12px
  font-size 25px

/** エラー */
.err-msg
  margin 12px
  font-size 20px
  color hotpink
↑Stylusの記法
マイケル
マイケル
上記のように&-で繋げると、親階層のクラス名に付け加える形で変換されます。
(category、category-border、category-itemなど)
エレキベア
エレキベア
中々コンパクトに書けるクマね
マイケル
マイケル
CSSメタ言語には他にもSASS、Lessといったものがあったり、
MaterialUI、TailwindCSSといったCSSライブラリを使用する方法もあります!
こちらも興味ある方は調べてみてください!
型定義ファイル
マイケル
マイケル
そしてもう一つ、types.d.tsxについて!
こちらはTypeScriptの型定義をまとめたファイルになります!
/** Props型定義 */
declare namespace PropsType {
    type ArticleList ={
        categorySlug: string,
    }
}

/** State型定義 */
declare namespace StateType {
    // ArticleList
    type PostCategory = {
        id: number,
        name: string,
    };
    type Post = {
        id: number,
        title: string,
        url: string,
        image: string,
        categories: PostCategory[],
        date: string,
    };
    type ArticleListState = {
        posts: Post[],
        posts_display_count: number,
        posts_all_count: number,
        error: boolean,
    };
    // Categories
    type Category = {
        id: number,
        name: string,
        slug: string,
        is_selected: boolean,
    };
    type CategoriesState = {
        categories: Category[],
        error: boolean,
    };
    // 共通
    type ReducerState = {
        articleListState: ArticleListState,
        categoriesState: CategoriesState,
    }
}

/** JSON型定義 */
declare namespace JsonType {
    // ArticleList
    type PostCategory = {
        [k: string]: {
            ID: number,
            name: string,
        }
    };
    type Post = {
        ID: number,
        title: string,
        URL: string,
        terms: {
            category: PostCategory[],
        },
        featured_image: string,
        date: string,
    };
    type PostsResponse = {
        found: number,
        posts: Post[],
    };
    // Categories
    type Category = {
        ID: number,
        name: string,
        slug: string,
        parent: number,
    };
    type CategoriesResponse = {
        categories: Category[],
    };
}
↑型定義をまとめたファイル
マイケル
マイケル
拡張子には.dを付けるのが作法になり、型情報のルートには
declare(アンビエント宣言)を付与する必要があります。
エレキベア
エレキベア
型の情報はばらばらにせずにここにまとめるクマね

環境構築

マイケル
マイケル
それでは環境を作っていきます!
まずは必要なパッケージをインストールします!
// package.jsonの作成
npm init -y

// TypeScript関連のパッケージをインストール
npm install --save-dev typescript ts-loader

// React関連のパッケージをインストール
npm install --save react react-dom react-router-dom
npm install --save-dev @types/react @types/react-dom @types/react-router-dom

// webpack関連のパッケージをインストール
npm install --save-dev webpack webpack-cli webpack-dev-server

// Redux関連もインストール
npm install --save redux react-redux redux-logger redux-thunk
npm install --save-dev @types/redux @types/react-redux @types/redux-logger @types/redux-thunk

// Stylus関連のパッケージをインストール
npm install --save-dev stylus stylus-loader css-loader mini-css-extract-plugin

↑必要パッケージのインストール

マイケル
マイケル
Stylus関連のパッケージが増えていますが、
基本的には前回と同じものになります。
エレキベア
エレキベア
大分インストールするパッケージも増えてきたクマね〜〜
マイケル
マイケル
インストールしたら、
tsconfig.jsonwebpack.config.jsの作成
package.json への build、startコマンド追加
を行いましょう!
const path = require('path');
const MiniCssExtractPlugin = require("mini-css-extract-plugin");

module.exports = {
    // 開発用モードで出力
    mode: "development",
    // メインとなるファイル
    entry: './src/ts/index.tsx',
    module: {
        rules: [
            {
                test: /\.tsx?$/,
                use: 'ts-loader',
                exclude: /node_modules/,
            },
            {
                test: /\.styl?$/,
                use: [
                    MiniCssExtractPlugin.loader,
                    'css-loader',
                    'stylus-loader'
                ],
            },
        ],
    },
    resolve: {
        extensions: ['.tsx', '.ts', '.js'],
    },
    output: {
        filename: 'bundle.js',
        path: path.resolve(__dirname, 'dist'),
    },
    devServer: {
        open: true,
        contentBase: path.join(__dirname, 'dist'),
        watchContentBase: true,
        port: 8080,
    },
    plugins: [
        new MiniCssExtractPlugin({
            filename: "style.css",
        })
    ],
};

↑webpack.config.jsの作成(Stylus関連の記述も有り)


{
  "compilerOptions": {
    "target": "es5",
    "module": "commonjs",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "outDir": "src/js",
    "jsx": "react"
  },
  "include": [
    "src/ts/**/*"
  ]
}

↑tsconfig.jsonの作成


{
  "name": "04_todo-app_react-ts",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "webpack",
    "start": "webpack serve"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@types/react-dom": "^17.0.9",
    "ts-loader": "^9.2.3",
    "typescript": "^4.3.5",
    "webpack": "^5.44.0",
    "webpack-cli": "^4.7.2",
    "webpack-dev-server": "^3.11.2"
  },
  "dependencies": {
    "react": "^17.0.2",
    "react-dom": "^17.0.2"
  }
}

↑build、startコマンドの追加

エレキベア
エレキベア
環境構築完了クマ〜〜〜

実装内容の解説

マイケル
マイケル
環境が出来たらコードを書いていきましょう!

ソースコード全体

マイケル
マイケル
とはいったものの、ページの都合上になるのですが
全てのコードを載せた後に要点を解説する流れで進めようと思います・・・。
マイケル
マイケル
実際に開発する流れとしては

・取得する仮データを用意してモックを作成
・APIからの取得処理を実装

といった流れになると思うので、ぜひ自身で作ってみてから比べてみてください!
エレキベア
エレキベア
めんどくさいから見るクマ〜〜〜
ScreenShot 2021 07 25 14 05 26 2
↑画面イメージ

[GitHub]
masarito617/react-study – GitHub
 - 06_wp-app-react-ts

src
├── dist
│   └── index.html
├── styl
│   └── index.styl
└── ts
    ├── actions
    │   ├── ActionTypes.tsx
    │   ├── ArticleList.tsx
    │   └── Categories.tsx
    ├── components
    │   ├── ArticleList.tsx
    │   └── Categories.tsx
    ├── reducers
    │   ├── ArticleList.tsx
    │   └── Categories.tsx
    ├── types
    │   ├── ArticleList.tsx
    │   └── types.d.tsx
    ├── App.tsx
    └── index.tsx
↑フォルダ構成
マイケル
マイケル
まず大元の部分は下記になります。
index.tsxでReducerとStoreの登録App.tsxでルーティング処理を行なっています。
<!doctype html>
<html>
<head>
    <link rel="stylesheet" href="style.css">
    <title>都会のエレキベア</title>
</head>
<body>
<div id="root"></div>
<script src="./bundle.js"></script>
</body>
</html>
import React from 'react';
import ReactDOM from "react-dom";
import {createStore, combineReducers, applyMiddleware} from "redux";
import {Provider} from "react-redux";
import {BrowserRouter} from "react-router-dom";
import {logger} from "redux-logger";
import thunk from "redux-thunk";
import App from "./App";
import articleListReducer from "./reducers/ArticleList";
import categoriesReducer from "./reducers/Categories";

// 全てのReducerを定義
let reducers = combineReducers({
    articleListState: articleListReducer,
    categoriesState: categoriesReducer
});

const store = createStore(
    reducers,
    applyMiddleware(logger, thunk)
);

ReactDOM.render(
    <Provider store={store}>
        <BrowserRouter>
            <App />
        </BrowserRouter>
    </Provider>,
    document.getElementById('root')
);
import React from "react";
import {Switch, Route, Redirect} from "react-router-dom";
import ArticleList from "./components/ArticleList";
import Categories from "./components/Categories";
import '../styl/index.styl';

export default function App() {
    return (
        <div>
            <div className="app-bar">
                <div className="title">都会のエレキベア</div>
            </div>
            <Categories />
            <Switch>
                <Route exact path="/" render={() => <ArticleList categorySlug={""}/>} />
                <Route exact path="/category/" render={() => <Redirect to="/" />} />
                <Route exact path="/category/:slug"
                       render={({match: match}) => <ArticleList categorySlug={match.params.slug}/>} />
            </Switch>
        </div>
    );
}
Components
マイケル
マイケル
Componentsはどちらもレンダリング時にAPIから情報を取得し、
取得出来たら表示する
といった処理になっています。
import React, {useEffect} from "react";
import {useDispatch, useSelector} from "react-redux";
import '../../styl/index.styl';
import {fetchPostData} from "../actions/ArticleList";

function ArticleList(props: PropsType.ArticleList) {
    const dispatch = useDispatch();
    const state = useSelector((state: StateType.ReducerState) => state.articleListState);

    // レンダリング時に記事データを取得する
    useEffect(() => {
        dispatch(fetchPostData(props.categorySlug));
    }, [props.categorySlug]);

    if (state.error) {
        // エラー発生時
        return (
            <div className="article-area">
                <div className="err-msg">読込中にエラーが発生しました。</div>
            </div>
        );

    } else if (state.posts == null || Object.keys(state.posts).length == 0) {
        // 記事データロード中
        return (
            <div className="article-area">
                <div className="load-msg">Now Loading...</div>
            </div>
        );

    } else {
        // 記事データ取得後の表示
        return (
            <div className="article-area">
                <div className="article-count">検索結果 {state.posts_all_count} 件のうち {state.posts_display_count} 件を表示しています。</div>
                {
                    state.posts.map((post: StateType.Post) => {
                        return (
                            <div key={post.id} className="article-card" onClick={() => window.open(post.url)}>
                                <img className="article-card-image" src={post.image} />
                                <div className="article-card-title">{post.title}</div>
                                <div className="article-card-category">
                                    {
                                        post.categories.map((category: StateType.PostCategory) => {
                                            return <span key={category.id} className="article-card-category-item">{category.name}</span>;
                                        })
                                    }
                                </div>
                                <div className="article-card-date">{post.date.slice(0, 10)}</div>
                            </div>
                        );
                    })
                }
            </div>
        );
    }
}
export default ArticleList;
import React, {useEffect} from "react";
import {useDispatch, useSelector} from "react-redux";
import {useHistory} from "react-router-dom";
import '../../styl/index.styl';
import {fetchCategoryData, selectCategoryId} from "../actions/Categories";

function Categories() {
    const dispatch = useDispatch();
    const categories = useSelector((state: StateType.ReducerState) => state.categoriesState.categories);
    const history = useHistory();

    // レンダリング時にカテゴリーデータを取得する
    useEffect(() => {
        dispatch(fetchCategoryData());
    }, []);

    // 押下されたカテゴリーページに遷移する
    const pushCategoryPage = (selectCategory: StateType.Category): void => {
        dispatch(selectCategoryId(selectCategory.id));
        history.push(`/category/${selectCategory.slug}`)
    };

    // カテゴリーの表示
    return (
        <div className="category">
            {
                categories.map((category: StateType.Category) => {
                    return (
                        <React.Fragment key={category.id}>
                            <button type="button" className="category-item"
                                    disabled={category.is_selected}
                                    onClick={() => pushCategoryPage(category)}>{category.name}</button>
                        </React.Fragment>
                    );
                })
            }
        </div>
    );
}
export default Categories;
Actions
マイケル
マイケル
Actionsは前回と同じようにActionTypesを指定して、それに対応する処理を記述しています。
またthunkを使って非同期でJSONを取得する処理も入っています。
/* ActionTypes定義 */
export = {
    // ArticleList
    START_POSTS_REQUEST: "START_POSTS_REQUEST",
    RECEIVE_POSTS_DATA: "RECEIVE_POSTS_DATA",
    // Categories
    START_CATEGORY_REQUEST: "START_CATEGORY_REQUEST",
    RECEIVE_CATEGORY_DATA: "RECEIVE_CATEGORY_DATA",
    SELECT_CATEGORY: "SELECT_CATEGORY",
} as const;
import {Action, Dispatch} from "redux";
import ActionTypes from "./ActionTypes";

// 記事データ取得URL
const POSTS_API_URL: string = 'https://public-api.wordpress.com/rest/v1.1/sites/elekibear.com/posts';

/**
 * リクエスト開始Action
 */
const startRequest = () => ({
    type: ActionTypes.START_POSTS_REQUEST,
    payload: {},
});

/**
 * データ受信完了Action
 */
const receiveData = (error: any, json: JsonType.PostsResponse | null) => ({
    type: ActionTypes.RECEIVE_POSTS_DATA,
    payload: { error, json },
});

/**
 * JSONデータ取得処理
 * @param url 取得対象のURL
 */
const fetchPostsJson = (url: string) => {
    return fetch(url)
        .then((response: Response) => {
            return response.json();
        })
        .catch((e: any) => {
            throw e;
        });
}

/**
 * 記事データを取得処理
 * @param categorySlug カテゴリースラッグ
 */
export function fetchPostData(categorySlug: string) {
    return async (dispatch: Dispatch<Action>) => {
        // リクエスト開始
        dispatch(startRequest());
        try {
            // URLからJSONを取得
            let url: string = POSTS_API_URL;
            // カテゴリースラッグをリクエストパラメータに追加
            if (categorySlug) {
                url += "?category=" + categorySlug;
            }
            const json: JsonType.PostsResponse = await fetchPostsJson(url);
            // データ受信完了
            dispatch(receiveData(null, json));
        } catch (e) {
            // エラー発生時
            dispatch(receiveData(e, null));
        }
    };
}

// ActionをUnionTypeで定義
export type ArticleListActions =
    ReturnType<typeof startRequest> |
    ReturnType<typeof receiveData>;
import {Action, Dispatch} from "redux";
import ActionTypes from "./ActionTypes";

// カテゴリー取得URL
const CATEGORY_API_URL: string = "https://public-api.wordpress.com/rest/v1.1/sites/elekibear.com/categories";

/**
 * リクエスト開始Action
 */
const startRequest = () => ({
    type: ActionTypes.START_CATEGORY_REQUEST,
    payload: {},
});

/**
 * データ受信完了Action
 */
const receiveData = (error: any, json: JsonType.CategoriesResponse | null) => ({
    type: ActionTypes.RECEIVE_CATEGORY_DATA,
    payload: { error, json },
});

/**
 * JSONデータ取得処理
 * @param url 取得対象のURL
 */
const fetchCategoryJson = (url: string) => {
    return fetch(url)
        .then((response: Response) => {
            return response.json();
        })
        .catch((e: any) => {
            throw e;
        });
}

/**
 * カテゴリー情報取得処理
 */
export function fetchCategoryData() {
    return async (dispatch: Dispatch<Action>) => {
        // リクエスト開始
        dispatch(startRequest());
        try {
            // URLからJSONを取得
            const json: JsonType.CategoriesResponse = await fetchCategoryJson(CATEGORY_API_URL);
            // データ受信完了
            dispatch(receiveData(null, json));
        } catch (e) {
            // エラー発生時
            dispatch(receiveData(e, null));
        }
    };
}

/**
 * カテゴリー選択処理
 * @param selectCategoryId 選択したカテゴリーID
 */
export function selectCategoryId(selectCategoryId: number) {
    return {
        type: ActionTypes.SELECT_CATEGORY,
        payload: {selectCategoryId},
    };
}

// ActionをUnionTypeで定義
export type CategoriesActions =
    ReturnType<typeof startRequest> |
    ReturnType<typeof receiveData> |
    ReturnType<typeof selectCategoryId>;
Reducers
マイケル
マイケル
そして最後にReducers!
こちらは取得したJSONをStateに変換する処理も含めています。
import {ArticleListActions} from "../actions/ArticleList";
import ActionTypes from "../actions/ActionTypes";

const initialState: StateType.ArticleListState = {
    posts: [],
    posts_display_count: 0,
    posts_all_count: 0,
    error: false,
}

/**
 * 記事データ取得処理
 * @param json 取得したJSONデータ
 */
const getPostsInfo = (json: JsonType.PostsResponse | null): StateType.ArticleListState => {
    // State定義
    const state: StateType.ArticleListState = {
        posts: [],
        posts_display_count: 0,
        posts_all_count: 0,
        error: false,
    };

    // JSONデータがNULLの場合、処理終了
    if (json == null) return state;

    // JSONデータからStateに変換して格納
    const postsData: JsonType.Post[] = json.posts;
    for (let i = 0; i < json.posts.length; i++) {
        // 記事データを取り出す
        const post: JsonType.Post = postsData[i];
        const categories = Object.entries(post.terms.category).map(([name, category]: [string, any]) => {
            return {
                id: category.ID,
                name: name,
            }
        });
        // 記事データを格納
        state.posts.push({
            id: post.ID,
            title: post.title,
            url: post.URL,
            image: post.featured_image,
            categories: categories,
            date: post.date,
        });
    }
    // 記事件数を設定
    state.posts_all_count = json.found;
    state.posts_display_count = state.posts.length;
    return state;
}

export default function articleListReducer(state: StateType.ArticleListState = initialState,
                                           action: ArticleListActions): StateType.ArticleListState {
    switch (action.type) {
        // リクエスト開始時に値を初期化
        case ActionTypes.START_POSTS_REQUEST:
            return {
                posts: [],
                posts_display_count: 0,
                posts_all_count: 0,
                error: false,
            };
        // データ受信時にPostデータを設定
        case ActionTypes.RECEIVE_POSTS_DATA:
            return action.payload.error
                ? {...state, error: true}
                : getPostsInfo(action.payload.json);
        default:
            return state;
    }
}
import {CategoriesActions} from "../actions/Categories";
import ActionTypes from "../actions/ActionTypes";

const initialState: StateType.CategoriesState = {
    categories: [
        {
            id: 0,
            name: "ALL",
            slug: "",
            is_selected: true,
        },
    ],
    error: false,
}

/**
 * カテゴリー取得処理
 * @param json 取得したJSONデータ
 */
const getCategories = (json: JsonType.CategoriesResponse | null): StateType.Category[] => {
    // State定義
    const categories: StateType.Category[] = initialState.categories;

    // JSONデータがNULLの場合、処理終了
    if (json == null) return categories;

    // JSONデータからStateに変換
    const categoriesData: JsonType.Category[] = json.categories;
    for (let i = 0; i < categoriesData.length; i++) {
        const category: JsonType.Category = categoriesData[i];
        // 親カテゴリーのみ取り出す(ID1は未分類のため省く)
        if (category.parent == 0 && category.ID != 1) {
            categories.push({
                id: category.ID,
                name: category.name,
                slug: category.slug,
                is_selected: false,
            });
        }
    }
    // ID昇順でソート
    categories.sort(function(a, b) {
        if (a.id > b.id) {
            return 1;
        } else {
            return -1;
        }
    })
    return categories;
}

/**
 * カテゴリー選択処理
 * @param categories stateのカテゴリー情報
 * @param selectCategoryId 選択されたカテゴリーID
 */
const selectCategory = (categories: StateType.Category[], selectCategoryId: number): StateType.Category[] => {
    // Stateのカテゴリーをコピー
    let changeCategories: StateType.Category[] = [...categories];
    // 選択状態を更新して返却
    changeCategories.map((category: StateType.Category) => {
        category.is_selected = category.id == selectCategoryId;
    })
    return changeCategories;
}

export default function categoriesReducer(state: StateType.CategoriesState = initialState,
                                          action: CategoriesActions): StateType.CategoriesState {
    switch (action.type) {
        // リクエスト開始時に値を初期化
        case ActionTypes.START_CATEGORY_REQUEST:
            return {
                categories: [
                    {
                        id: 0,
                        name: "ALL",
                        slug: "",
                        is_selected: true,
                    },
                ],
                error: false,
            };
        // データ受信時にCategoryデータを設定
        case ActionTypes.RECEIVE_CATEGORY_DATA:
            return action.payload.error
                ? {...state, error: true}
                : {...state, categories: getCategories(action.payload.json)};
        // カテゴリー選択時
        case ActionTypes.SELECT_CATEGORY:
            return {
                ... state,
                categories: selectCategory(state.categories, action.payload.selectCategoryId),
                error: false,
            }
        default:
            return state;
    }
}
マイケル
マイケル
以上が全体のコードになりますが、
基本的にはArticleListもCategoriesも同じような構造になっていたかと思います!
エレキベア
エレキベア
多すぎてよくわからないクマ〜〜・・・
マイケル
マイケル
それじゃ次は、前回と異なる部分に注目しながら
要所要所を見ていこう!

複数のReducer

マイケル
マイケル
今回は2つのReducerを使うため、index.tsxでcombineReducersをインポートしています。
下記のように記述することでReducerをまとめてStoreを作成することができます。
import React from 'react';
import ReactDOM from "react-dom";
import {createStore, combineReducers, applyMiddleware} from "redux";
import {Provider} from "react-redux";
import {BrowserRouter} from "react-router-dom";
import {logger} from "redux-logger";
import thunk from "redux-thunk";
import App from "./App";
import articleListReducer from "./reducers/ArticleList";
import categoriesReducer from "./reducers/Categories";

// 全てのReducerを定義
let reducers = combineReducers({
    articleListState: articleListReducer,
    categoriesState: categoriesReducer
});

const store = createStore(
    reducers,
    applyMiddleware(logger, thunk)
);

ReactDOM.render(
    <Provider store={store}>
        <BrowserRouter>
            <App />
        </BrowserRouter>
    </Provider>,
    document.getElementById('root')
);
↑combineReducersを使ったStoreの作成

ルーティング

マイケル
マイケル
そして次はページのルーティング!
カテゴリーが押下されたら「category/:slug」というURLでルーティングするようにしています。
エレキベア
エレキベア
「:slug」の部分をコンポーネントに渡しているクマね
マイケル
マイケル
ルーティングにも様々な方法がありますが、今回は下記のように、

・index.tsx内でBrowserRouterタグで囲む。
・App.tsx内でSwitchタグで分岐させる

といった方法を使いました。
import React from 'react';
import ReactDOM from "react-dom";
import {createStore, combineReducers, applyMiddleware} from "redux";
import {Provider} from "react-redux";
import {BrowserRouter} from "react-router-dom";
import {logger} from "redux-logger";
import thunk from "redux-thunk";
import App from "./App";
import articleListReducer from "./reducers/ArticleList";
import categoriesReducer from "./reducers/Categories";

// 全てのReducerを定義
let reducers = combineReducers({
    articleListState: articleListReducer,
    categoriesState: categoriesReducer
});

const store = createStore(
    reducers,
    applyMiddleware(logger, thunk)
);

ReactDOM.render(
    <Provider store={store}>
        <BrowserRouter>
            <App />
        </BrowserRouter>
    </Provider>,
    document.getElementById('root')
);
↑BrowserRouterタグで囲む
import React from "react";
import {Switch, Route, Redirect} from "react-router-dom";
import ArticleList from "./components/ArticleList";
import Categories from "./components/Categories";
import '../styl/index.styl';

export default function App() {
    return (
        <div>
            <div className="app-bar">
                <div className="title">都会のエレキベア</div>
            </div>
            <Categories />
            <Switch>
                <Route exact path="/" render={() => <ArticleList categorySlug={""}/>} />
                <Route exact path="/category/" render={() => <Redirect to="/" />} />
                <Route exact path="/category/:slug"
                       render={({match: match}) => <ArticleList categorySlug={match.params.slug}/>} />
            </Switch>
        </div>
    );
}
↑Switchタグで分岐させる
マイケル
マイケル
Componentsでは、押下されたカテゴリースラッグを含めたURLを
history.push()に渡すことで遷移しています。
historyを取得しているuseHistory()はReactHooksの1つになります!
import React, {useEffect} from "react";
import {useDispatch, useSelector} from "react-redux";
import {useHistory} from "react-router-dom";
import '../../styl/index.styl';
import {fetchCategoryData, selectCategoryId} from "../actions/Categories";

function Categories() {
    const dispatch = useDispatch();
    const categories = useSelector((state: StateType.ReducerState) => state.categoriesState.categories);
    const history = useHistory();

    // レンダリング時にカテゴリーデータを取得する
    useEffect(() => {
        dispatch(fetchCategoryData());
    }, []);

    // 押下されたカテゴリーページに遷移する
    const pushCategoryPage = (selectCategory: StateType.Category): void => {
        dispatch(selectCategoryId(selectCategory.id));
        history.push(`/category/${selectCategory.slug}`)
    };

    // カテゴリーの表示
    return (
        <div className="category">
            {
                categories.map((category: StateType.Category) => {
                    return (
                        <React.Fragment key={category.id}>
                            <button type="button" className="category-item"
                                    disabled={category.is_selected}
                                    onClick={() => pushCategoryPage(category)}>{category.name}</button>
                        </React.Fragment>
                    );
                })
            }
        </div>
    );
}
export default Categories;
↑カテゴリーを押下したら遷移する
エレキベア
エレキベア
ReactHooksも懐かしいクマね〜〜

非同期処理

マイケル
マイケル
最後に非同期でAPIからJSONを取得している部分を解説します!
流れとしてはまず、コンポーネントがレンダリングされた時にfetchアクションを実行します。
import React, {useEffect} from "react";
import {useDispatch, useSelector} from "react-redux";
import '../../styl/index.styl';
import {fetchPostData} from "../actions/ArticleList";

function ArticleList(props: PropsType.ArticleList) {
    const dispatch = useDispatch();
    const state = useSelector((state: StateType.ReducerState) => state.articleListState);

    // レンダリング時に記事データを取得する
    useEffect(() => {
        dispatch(fetchPostData(props.categorySlug));
    }, [props.categorySlug]);

    if (state.error) {
        // エラー発生時
        return (
            <div className="article-area">
                <div className="err-msg">読込中にエラーが発生しました。</div>
            </div>
        );

    } else if (state.posts == null || Object.keys(state.posts).length == 0) {
        // 記事データロード中
        return (
            <div className="article-area">
                <div className="load-msg">Now Loading...</div>
            </div>
        );

    } else {
        // 記事データ取得後の表示
        return (
            <div className="article-area">
                <div className="article-count">検索結果 {state.posts_all_count} 件のうち {state.posts_display_count} 件を表示しています。</div>
                {
                    state.posts.map((post: StateType.Post) => {
                        return (
                            <div key={post.id} className="article-card" onClick={() => window.open(post.url)}>
                                <img className="article-card-image" src={post.image} />
                                <div className="article-card-title">{post.title}</div>
                                <div className="article-card-category">
                                    {
                                        post.categories.map((category: StateType.PostCategory) => {
                                            return <span key={category.id} className="article-card-category-item">{category.name}</span>;
                                        })
                                    }
                                </div>
                                <div className="article-card-date">{post.date.slice(0, 10)}</div>
                            </div>
                        );
                    })
                }
            </div>
        );
    }
}
export default ArticleList;
↑fetchPostDataアクションの実行
マイケル
マイケル
useEffectレンダリング時に実行されるReactHooksで、第2引数で渡した要素が変更された際にも再描画されるという便利Hooksです!
エレキベア
エレキベア
またもやReactHooksクマね〜〜〜
マイケル
マイケル
Action内ではAPIへのリクエスト開始時にStateを初期化して、
取得したJSONデータを返却する
といった処理になっています。
import {Action, Dispatch} from "redux";
import ActionTypes from "./ActionTypes";

// 記事データ取得URL
const POSTS_API_URL: string = 'https://public-api.wordpress.com/rest/v1.1/sites/elekibear.com/posts';

/**
 * リクエスト開始Action
 */
const startRequest = () => ({
    type: ActionTypes.START_POSTS_REQUEST,
    payload: {},
});

/**
 * データ受信完了Action
 */
const receiveData = (error: any, json: JsonType.PostsResponse | null) => ({
    type: ActionTypes.RECEIVE_POSTS_DATA,
    payload: { error, json },
});

/**
 * JSONデータ取得処理
 * @param url 取得対象のURL
 */
const fetchPostsJson = (url: string) => {
    return fetch(url)
        .then((response: Response) => {
            return response.json();
        })
        .catch((e: any) => {
            throw e;
        });
}

/**
 * 記事データを取得処理
 * @param categorySlug カテゴリースラッグ
 */
export function fetchPostData(categorySlug: string) {
    return async (dispatch: Dispatch<Action>) => {
        // リクエスト開始
        dispatch(startRequest());
        try {
            // URLからJSONを取得
            let url: string = POSTS_API_URL;
            // カテゴリースラッグをリクエストパラメータに追加
            if (categorySlug) {
                url += "?category=" + categorySlug;
            }
            const json: JsonType.PostsResponse = await fetchPostsJson(url);
            // データ受信完了
            dispatch(receiveData(null, json));
        } catch (e) {
            // エラー発生時
            dispatch(receiveData(e, null));
        }
    };
}

// ActionをUnionTypeで定義
export type ArticleListActions =
    ReturnType<typeof startRequest> |
    ReturnType<typeof receiveData>;
↑JSONデータの取得
マイケル
マイケル
最後にReducer内でJSONデータをState要素に変換して格納しています。
このような処理はTypeScriptで型定義してあると変数名などの間違いも起こりにくく、恩恵を感じやすいポイントだと思います。
import {ArticleListActions} from "../actions/ArticleList";
import ActionTypes from "../actions/ActionTypes";

const initialState: StateType.ArticleListState = {
    posts: [],
    posts_display_count: 0,
    posts_all_count: 0,
    error: false,
}

/**
 * 記事データ取得処理
 * @param json 取得したJSONデータ
 */
const getPostsInfo = (json: JsonType.PostsResponse | null): StateType.ArticleListState => {
    // State定義
    const state: StateType.ArticleListState = {
        posts: [],
        posts_display_count: 0,
        posts_all_count: 0,
        error: false,
    };

    // JSONデータがNULLの場合、処理終了
    if (json == null) return state;

    // JSONデータからStateに変換して格納
    const postsData: JsonType.Post[] = json.posts;
    for (let i = 0; i < json.posts.length; i++) {
        // 記事データを取り出す
        const post: JsonType.Post = postsData[i];
        const categories = Object.entries(post.terms.category).map(([name, category]: [string, any]) => {
            return {
                id: category.ID,
                name: name,
            }
        });
        // 記事データを格納
        state.posts.push({
            id: post.ID,
            title: post.title,
            url: post.URL,
            image: post.featured_image,
            categories: categories,
            date: post.date,
        });
    }
    // 記事件数を設定
    state.posts_all_count = json.found;
    state.posts_display_count = state.posts.length;
    return state;
}

export default function articleListReducer(state: StateType.ArticleListState = initialState,
                                           action: ArticleListActions): StateType.ArticleListState {
    switch (action.type) {
        // リクエスト開始時に値を初期化
        case ActionTypes.START_POSTS_REQUEST:
            return {
                posts: [],
                posts_display_count: 0,
                posts_all_count: 0,
                error: false,
            };
        // データ受信時にPostデータを設定
        case ActionTypes.RECEIVE_POSTS_DATA:
            return action.payload.error
                ? {...state, error: true}
                : getPostsInfo(action.payload.json);
        default:
            return state;
    }
}
↑JSONデータをStateに格納
マイケル
マイケル
以上が非同期処理の流れになります。
ArticleListの例を載せましたが、Categoriesも同じような処理になっています。
エレキベア
エレキベア
これで大体処理の内容が分かったクマ〜〜〜

おわりに

マイケル
マイケル
そんなこんなでアプリを作ってみましたがどうだったでしょうか?
エレキベア
エレキベア
前回と比べたら量も多かったクマが、
1つ1つ見ていけば大体理解できたクマ〜〜〜
マイケル
マイケル
これでReactアプリ開発シリーズは一旦終了です!!
最後まで見てくれた方はおつかれさまでした!
気が向いたらNext.jsやReactNativeも触って記事にしてみようかなとも思っていますがするか分かりません・・・。
エレキベア
エレキベア
長いシリーズおつかれクマ
フロントエンド開発も中々楽しいクマね
マイケル
マイケル
みんなReactReact言うから触りたくもなるよね・・・(ボソッ)
どうせならAPI開発も勉強してWebサービスを作ったり、
ソシャゲ開発にも生かしてみたいね!
エレキベア
エレキベア
よいこのみんなは流行りばかりに流されちゃダメクマよ
マイケル
マイケル
それでは今日はこの辺で!
アデュー!!
エレキベア
エレキベア
クマ〜〜〜〜

【React.js】第三回 Reactでアプリ開発! 〜APIを使用した実践的なアプリを開発するぜ編〜 〜完〜

コメント