
マイケル
みなさんこんにちは!
マイケルです!
マイケルです!

エレキベア
こんにちクマ〜〜

マイケル
今日は前回に引き続きReactアプリ開発!
これまで勉強してきたRedux、TypeScriptといった知識を生かしながら、
APIを使用した実践的なReactアプリを作ってみるよ!
これまで勉強してきたRedux、TypeScriptといった知識を生かしながら、
APIを使用した実践的なReactアプリを作ってみるよ!

エレキベア
ついにちゃんとしたのが開発できるクマね

マイケル
具体的には下記のように、WordPress記事をAPI経由で取得して表示する
アプリを作ります!
アプリを作ります!

↑選択したカテゴリーの記事を表示するアプリ

エレキベア
(このブログクマ・・・。)
こんなの簡単クマよ〜〜〜
こんなの簡単クマよ〜〜〜

マイケル
このブログの記事はREST APIで取得できるようになってるから使ってみたよ!
簡単そうに見えるけどこれだけでも結構手間なんだぜ・・・。
簡単そうに見えるけどこれだけでも結構手間なんだぜ・・・。

エレキベア
とりあえず早く教えるクマ

マイケル
それじゃ始めていこう!
ソースコードについてはこれまでと同様、GitHubに上げているので参考に使ってください!
ソースコードについてはこれまでと同様、GitHubに上げているので参考に使ってください!
[対象フォルダ]
masarito617/react-study – GitHub
- 06_wp-app-react-ts

エレキベア
クマ〜〜〜〜
参考書籍

マイケル
参考書籍についてはこれまでと同じく以下の2冊になります!
更に知識を深めたい方は是非読んでみてください!
更に知識を深めたい方は是非読んでみてください!
React入門 React・Reduxの導入からサーバサイドレンダリングによるUXの向上まで (NEXT ONE)

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

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

マイケル
この中で、今回使用するリクエストは以下になります!
- カテゴリ取得
- 記事データ取得
- 記事データ取得(カテゴリ指定時)
CATEGORIES – WordPress REST API
https://public-api.wordpress.com/rest/v1.1/sites/$site/categories
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を指定します。
今回はこのブログを使うため、elekibear.comを指定します。

エレキベア
こんなブログなので是非勉強に使ってくれクマ
全体の構成

マイケル
ざっと全体の作りについて説明します!
前回と同様、React、TypeScript環境を構築し、
下記のように大きく2つのコンポーネントに分けて作りました。
前回と同様、React、TypeScript環境を構築し、
下記のように大きく2つのコンポーネントに分けて作りました。

↑コンポーネントの分け方(Categories、ArticleList)

マイケル
Categoriesではカテゴリー一覧を取得して表示、
ArticleListでは記事一覧を取得して表示します。
また、カテゴリーが押下されたら記事を再取得するといった仕様になります。
ArticleListでは記事一覧を取得して表示します。
また、カテゴリーが押下されたら記事を再取得するといった仕様になります。

エレキベア
シンプルな構成クマ〜〜
フォルダ構成

マイケル
フォルダの構成は下記のようになっています。
こちらもReduxアーキテクチャを適用しており、前回とほとんど同じです。
こちらも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.stylとtypes.tsxは今回始めて出てきたクマね

マイケル
よく気づいたね!
その部分は前回とは少し違うから、解説しておこう!
その部分は前回とは少し違うから、解説しておこう!
前回との相違点
Stylusを使ったUI構築

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

マイケル
CSSメタ言語と呼ばれるものの1つで、TypeScript等と同様
ビルド時にCSSファイルに変換されます。
Stylusを使用すると{}等を省略できたり、階層構造で記述することができるといったメリットがあります!
ビルド時にCSSファイルに変換されます。
Stylusを使用すると{}等を省略できたり、階層構造で記述することができるといったメリットがあります!
↑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など)
(category、category-border、category-itemなど)

エレキベア
中々コンパクトに書けるクマね

マイケル
CSSメタ言語には他にもSASS、Lessといったものがあったり、
MaterialUI、TailwindCSSといったCSSライブラリを使用する方法もあります!
こちらも興味ある方は調べてみてください!
MaterialUI、TailwindCSSといったCSSライブラリを使用する方法もあります!
こちらも興味ある方は調べてみてください!
型定義ファイル

マイケル
そしてもう一つ、types.d.tsxについて!
こちらはTypeScriptの型定義をまとめたファイルになります!
こちらは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(アンビエント宣言)を付与する必要があります。
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.jsonとwebpack.config.jsの作成
・package.json への build、startコマンド追加
を行いましょう!
・tsconfig.jsonとwebpack.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からの取得処理を実装
といった流れになると思うので、ぜひ自身で作ってみてから比べてみてください!
・取得する仮データを用意してモックを作成
・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でルーティング処理を行なっています。
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を取得する処理も入っています。
また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に変換する処理も含めています。
こちらは取得した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も同じような構造になっていたかと思います!
基本的にはArticleListもCategoriesも同じような構造になっていたかと思います!

エレキベア
多すぎてよくわからないクマ〜〜・・・

マイケル
それじゃ次は、前回と異なる部分に注目しながら
要所要所を見ていこう!
要所要所を見ていこう!
複数のReducer

マイケル
今回は2つのReducerを使うため、index.tsxでcombineReducersをインポートしています。
下記のように記述することでReducerをまとめてStoreを作成することができます。
下記のように記述することで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でルーティングするようにしています。
カテゴリーが押下されたら「category/:slug」というURLでルーティングするようにしています。

エレキベア
「:slug」の部分をコンポーネントに渡しているクマね

マイケル
ルーティングにも様々な方法がありますが、今回は下記のように、
・index.tsx内でBrowserRouterタグで囲む。
・App.tsx内でSwitchタグで分岐させる
といった方法を使いました。
・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つになります!
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アクションを実行します。
流れとしてはまず、コンポーネントがレンダリングされた時に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データを返却するといった処理になっています。
取得した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で型定義してあると変数名などの間違いも起こりにくく、恩恵を感じやすいポイントだと思います。
このような処理は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も同じような処理になっています。
ArticleListの例を載せましたが、Categoriesも同じような処理になっています。

エレキベア
これで大体処理の内容が分かったクマ〜〜〜
おわりに

マイケル
そんなこんなでアプリを作ってみましたがどうだったでしょうか?

エレキベア
前回と比べたら量も多かったクマが、
1つ1つ見ていけば大体理解できたクマ〜〜〜
1つ1つ見ていけば大体理解できたクマ〜〜〜

マイケル
これでReactアプリ開発シリーズは一旦終了です!!
最後まで見てくれた方はおつかれさまでした!
気が向いたらNext.jsやReactNativeも触って記事にしてみようかなとも思っていますがするか分かりません・・・。
最後まで見てくれた方はおつかれさまでした!
気が向いたらNext.jsやReactNativeも触って記事にしてみようかなとも思っていますがするか分かりません・・・。

エレキベア
長いシリーズおつかれクマ
フロントエンド開発も中々楽しいクマね
フロントエンド開発も中々楽しいクマね

マイケル
みんなReactReact言うから触りたくもなるよね・・・(ボソッ)
どうせならAPI開発も勉強してWebサービスを作ったり、
ソシャゲ開発にも生かしてみたいね!
どうせならAPI開発も勉強してWebサービスを作ったり、
ソシャゲ開発にも生かしてみたいね!

エレキベア
よいこのみんなは流行りばかりに流されちゃダメクマよ

マイケル
それでは今日はこの辺で!
アデュー!!
アデュー!!

エレキベア
クマ〜〜〜〜
【React.js】第三回 Reactでアプリ開発! 〜APIを使用した実践的なアプリを開発するぜ編〜 〜完〜
コメント