ゲーム開発
Unity
UnrealEngine
C++
Blender
Houdini
ゲーム数学
ゲームAI
グラフィックス
サウンド
アニメーション
GBDK
制作日記
IT関連
ツール開発
フロントエンド関連
サーバサイド関連
WordPress関連
ソフトウェア設計
おすすめ技術書
音楽
DTM
楽器・機材
ピアノ
ラーメン日記
四コマ漫画
その他
おすすめアイテム
おもしろコラム
  • ゲーム開発
    • Unity
    • UnrealEngine
    • C++
    • Blender
    • Houdini
    • ゲーム数学
    • ゲームAI
    • グラフィックス
    • サウンド
    • アニメーション
    • GBDK
    • 制作日記
  • IT関連
    • ツール開発
    • フロントエンド関連
    • サーバサイド関連
    • WordPress関連
    • ソフトウェア設計
    • おすすめ技術書
  • 音楽
    • DTM
    • 楽器・機材
    • ピアノ
  • ラーメン日記
    • 四コマ漫画
      • その他
        • おすすめアイテム
        • おもしろコラム
      1. ホーム
      2. 20210727_01

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

      JavaScriptReactフロントエンド関連TypeScriptStylus
      2021-07-27

      マイケル
      マイケル
      みなさんこんにちは!
      マイケルです!
      エレキベア
      エレキベア
      こんにちクマ〜〜
      マイケル
      マイケル
      今日は前回に引き続きReactアプリ開発!
      これまで勉強してきたRedux、TypeScriptといった知識を生かしながら、
      APIを使用した実践的なReactアプリを作ってみるよ!
      エレキベア
      エレキベア
      ついにちゃんとしたのが開発できるクマね
      マイケル
      マイケル
      具体的には下記のように、WordPress記事をAPI経由で取得して表示する
      アプリを作ります!

      ↑選択したカテゴリーの記事を表示するアプリ
      エレキベア
      エレキベア
      (このブログクマ・・・。)
      こんなの簡単クマよ〜〜〜
      マイケル
      マイケル
      このブログの記事は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つのコンポーネントに分けて作りました。

      ↑コンポーネントの分け方(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からの取得処理を実装

      といった流れになると思うので、ぜひ自身で作ってみてから比べてみてください!
      エレキベア
      エレキベア
      めんどくさいから見るクマ〜〜〜

      ↑画面イメージ

      [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を使用した実践的なアプリを開発するぜ編〜 〜完〜


      JavaScriptReactフロントエンド関連TypeScriptStylus
      2021-07-27

      関連記事
      【ゲーム数学】第九回 p5.jsで学ぶゲーム数学「フーリエ解析」
      2024-05-12
      【Node.js】廃止されたAmazonアソシエイト画像リンクをAmazon Product Advertising API経由で復活させる
      2024-01-08
      【都会のエレキベア】ブログを大幅リニューアル!WordPressからNext.jsに移行するまでの流れをまとめる
      2024-01-01
      【Next.js】第四回 WordPressブログをNext.jsに移行する 〜サーバ移行・SEO・広告設定編〜
      2023-12-31
      【Next.js】第三回 WordPressブログをNext.jsに移行する 〜Markdown執筆環境構築編〜
      2023-12-31
      【Next.js】第二回 WordPressブログをNext.jsに移行する 〜WordPressデータの移行・表示編〜
      2023-12-31
      【Next.js】第一回 WordPressブログをNext.jsに移行する 〜全体設計、環境構築編〜
      2023-12-31
      【Electron × Vue3】カテゴリ情報のCSVデータを操作するツールを作る
      2023-12-31