はじめに
- この記事は React + Redux + TypeScript でモダンなwebアプリを作るチュートリアルを考えてみた① の続きです
- GitHubのリポジトリはこちら
GoogleBooksAPIを試してみる
- GoogleBooksAPIは登録なしで使用できる数少ない公開APIです
- 公式
- 例えば、本の検索は https://www.googleapis.com/books/v1/volumes?q=ジョジョ のように簡単に実行できます
- 公式リファレンス
Volume: list
- https://developers.google.com/books/docs/v1/reference/volumes/list
- 右側の
Try this API
からも様々な条件をつけて検索を試すことができます
axiosでAPIにリクエストを送る
- axiosを使うとHTTP通信を簡単に実装することができます
- まずはaxiosを追加しましょう
- 公式
terminal
yarn add axios
- 先ほどのGoogleBooksのAPIを叩く関数
searchGoogleBooks()
を作成してみましょう - アロー関数を使うことでより短く簡潔に関数リテラルを定義できます
- ここでは出てきませんが
this
を束縛できるという特徴もあります
- ここでは出てきませんが
- Promiseによる非同期処理には
async
await
を使うと簡単に書けます- アロー関数で使用する場合は引数の直前に
async
を記述します
- アロー関数で使用する場合は引数の直前に
-
axios.get()
のようにHTTPリクエストを送ることができます
Otameshi.tsx
import axios from 'axios';
const searchGoogleBooks = async (searchString: string) => {
const url = 'https://www.googleapis.com/books/v1/volumes';
const params = { q: searchString };
try {
const response = await axios.get(url, { params });
return { isSuccess: true, data: response.data, error: null };
} catch (error) {
return { isSuccess: false, data: null, error };
}
};
- Inputに入力した文字をクエリとし、ボタンをクリックすると検索を行うように変更してみます
Otameshi.tsx
export const Otameshi: React.FC = () => {
const [searchString, changeSearchString] = useState('');
const handleOnSearchButton = async (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
// form要素のbuttonのsubmitを止める
event.preventDefault();
const result = await searchGoogleBooks(searchString);
};
return (
<Wrapper>
<Body>
<Title>Google Books 検索</Title>
<SearchForm>
<Input placeholder='検索ワードを入力してね!' onChange={event => changeSearchString(event.target.value)} />
<SearchButton onClick={event => handleOnSearchButton(event)} disabled={!searchString}>
検索
</SearchButton>
</SearchForm>
</Body>
</Wrapper>
);
};
const Wrapper = styled.div`
display: flex;
justify-content: center;
margin-top: 20px;
`;
const Body = styled.div``;
const Title = styled.h1`
font-size: 24px;
font-weight: bold;
text-align: center;
`;
const Input = styled.input`
display: block;
box-sizing: border-box;
width: 250px;
font-size: 18px;
padding: 10px;
outline: none;
`;
const SearchForm = styled.form`
display: flex;
align-items: center;
justify-content: center;
margin-top: 20px;
`;
const SearchButton = styled.button`
color: #fff;
background-color: #09d3ac;
border-radius: 3px;
margin-left: 10px;
padding: 10px;
font-size: 18px;
border: none;
outline: none;
transition: 0.4s;
cursor: pointer;
&:disabled {
background-color: #bfbfbf;
cursor: not-allowed;
}
`;
- Chromeのディベロッパーツールなどでレスポンスが正常に返ってきていることを確認できます
- レスポンスを表示するように変更しましょう
-
useState
でレスポンスを保存するstatesearchResult
を新しく定義しています -
searchResult
に値が入っている場合のみ、searchResult
を表示するようにしています - Reactでは
map
のように繰り返しを使って要素を返す場合、key
にユニークな値を指定する必要があります
Otameshi.tsx
export const Otameshi: React.FC = () => {
const [searchString, changeSearchString] = useState('');
const [searchResult, changeSearchResult] = useState<any>(null);
const handleOnSearchButton = async (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
// form要素のbuttonのsubmitを止める
event.preventDefault();
const result = await searchGoogleBooks(searchString);
if (result.isSuccess) {
changeSearchResult(result.data);
} else {
window.alert(String(result.error));
}
};
return (
<Wrapper>
<Body>
<Title>Google Books 検索</Title>
<SearchForm>
<Input placeholder='検索ワードを入力してね!' onChange={event => changeSearchString(event.target.value)} />
<SearchButton onClick={event => handleOnSearchButton(event)} disabled={!searchString}>
検索
</SearchButton>
</SearchForm>
{searchResult && (
<ResultContent>
{searchResult.items.map((item: any) => {
return <ResultTitle key={item.id}>{item.volumeInfo.title}</ResultTitle>;
})}
</ResultContent>
)}
</Body>
</Wrapper>
);
};
const ResultContent = styled.div`
margin-top: 20px;
`;
const ResultTitle = styled.div`
padding: 10px 0;
border-bottom: 1px solid;
&:first-of-type {
border-top: 1px solid;
}
`;
- 次のように検索結果を表示できるようになりました
- 関連Commitはこちら
immutable.jsでレスポンスをモデル化する
-
このままだと
searchResult
の中身が不明確で、また想定外のデータが入ってしまうことが防げません -
ここで、
immutable.js
のRecord
を使ってモデル化することで、特定のkeyを必ず持つ不変オブジェクトとして取り扱うことができるようになります -
公式
-
また、日時操作のライブラリとして
dayjs
を使用するため追加しておきます
terminal
# TypeScriptに対応するためv4を使用する
yarn add immutable@^4.0.0-rc.12
yarn add dayjs
-
Google Books APIのリファレンスを参考にVolumeモデルを作成します
-
今回はレスポンスと同じ構造のモデルを
immutable.js
のRecord
で作成しています- 各Recordに
fromResponse
というレスポンスからモデルを生成するstaticメソッドを作っています - ネストする場合、
Map
を使うと任意のkeyを追加できてしまうため、ここでもRecordを使うようにしています - 配列の場合は
List
を使うようにしています
- 各Recordに
src/models/Volume.ts
import { List, Record } from 'immutable';
import dayjs, { Dayjs } from 'dayjs';
import { JSObject } from 'types/Common';
export class ImageLinks extends Record<{
smallThumbnail: string;
thumbnail: string;
}>({
smallThumbnail: '',
thumbnail: '',
}) {
static fromResponse(response: JSObject) {
const params = { ...response };
return new ImageLinks(params);
}
}
export class VolumeInfo extends Record<{
title: string;
subtitle: string;
authors: List<string>;
publisher: string;
publishedDate: Dayjs;
description: string;
imageLinks: ImageLinks;
previewLink: string;
infoLink: string;
canonicalVolumeLink: string;
}>({
title: '',
subtitle: '',
authors: List(),
publisher: '',
publishedDate: dayjs(),
description: '',
imageLinks: new ImageLinks(),
previewLink: '',
infoLink: '',
canonicalVolumeLink: '',
}) {
static fromResponse(response: JSObject) {
const params = { ...response };
params.authors = List(params.authors);
params.publishedDate = dayjs(params.publishedDate);
params.imageLinks = ImageLinks.fromResponse(params.imageLinks);
return new VolumeInfo(params);
}
get descriptionWithNewLine() {
return this.description.replace('。 ', '\n');
}
get publishedDateString() {
return this.publishedDate.format('YYYY/MM/DD');
}
}
export class Volume extends Record<{
id: number;
selfLink: string;
volumeInfo: VolumeInfo;
}>({
id: 0,
selfLink: '',
volumeInfo: new VolumeInfo(),
}) {
static fromResponse(response: JSObject) {
const params = { ...response };
params.volumeInfo = VolumeInfo.fromResponse(params.volumeInfo);
return new Volume(params);
}
}
- さらに
Volume: list
のレスポンスもモデル化します
src/models/Volume.ts
export class VolumeList extends Record<{
kind: string;
totalItems: number;
items: List<Volume>;
}>({
kind: '',
totalItems: 0,
items: List(),
}) {
static fromResponse(response: JSObject) {
const params = { ...response };
params.items = List(params.items.map((item: JSObject) => Volume.fromResponse(item)));
return new VolumeList(params);
}
}
-
関連Commitはこちら
-
実際に
searchResult
とAPIのレスポンスをモデルに置き換えてみましょう- Type付きでモデル化していればエディターのサジェストも出てくるので便利です
Otameshi.tsx
import { VolumeList } from 'models/Volume';
export const Otameshi: React.FC = () => {
const [searchString, changeSearchString] = useState('');
const [searchResult, changeSearchResult] = useState<VolumeList>(new VolumeList());
const handleOnSearchButton = async (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
// form要素のbuttonのsubmitを止める
event.preventDefault();
const result = await searchGoogleBooks(searchString);
if (result.isSuccess) {
changeSearchResult(VolumeList.fromResponse(result.data));
} else {
window.alert(String(result.error));
}
};
return (
<Wrapper>
<Body>
<Title>Google Books 検索</Title>
<SearchForm>
<Input placeholder='検索ワードを入力してね!' onChange={event => changeSearchString(event.target.value)} />
<SearchButton onClick={event => handleOnSearchButton(event)} disabled={!searchString}>
検索
</SearchButton>
</SearchForm>
{searchResult.kind && (
<ResultContent>
{searchResult.items.map(item => {
return <ResultTitle key={item.id}>{item.volumeInfo.title}</ResultTitle>;
})}
</ResultContent>
)}
</Body>
</Wrapper>
);
};
- これでレスポンスのデータも型安全に取り扱えるようになりました
- 関連Commitはこちら
Reduxを導入する
- Reduxを導入することでReactコンポーネントのファイルからロジックを分けて管理したり、別々のコンポーネントで同じstateを共通利用したりできるようになります
- 公式
- https://redux.js.org/
- Reduxには
Store
Action
Reducer
といった様々な要素が登場しますが、ここでは説明を省略するので初めて触れる方は他の記事など事前に目を通しておくことを推奨します
- まずは関連ライブラリをinstallしましょう
-
redux-logger
を使うとreduxのログをconsoleに出力することができます -
typescript-fsa
を使うとTypescript対応のRedux Actionを定義できます -
typescript-fsa-reducers
を使うとTypescript対応のRedux Reducerを定義できます -
connected-react-router
を使うことでreact-router
をReduxに対応できます
-
terminal
yarn add redux react-redux redux-logger typescript-fsa typescript-fsa-reducers connected-react-router
yarn add -D @types/react-redux @types/redux-logger
- 最初にReducerをまとめる
rootReducer
を定義します-
connectRouter
の引数にhistory
が必要なのでrootReducer
の引数として渡せるようにしてあります
-
src/reducers/index.ts
import { History } from 'history';
import { combineReducers } from 'redux';
import { RouterState, connectRouter } from 'connected-react-router';
export interface State {
router: RouterState;
}
export const rootReducer = (history: History) =>
combineReducers({
router: connectRouter(history),
});
- 次にStoreを定義する
configureStore
を用意します- 各種middlewareを挟む場合もここで行います
src/configureStore
import { applyMiddleware, createStore } from 'redux';
import { routerMiddleware } from 'connected-react-router';
import { createLogger } from 'redux-logger';
import { createBrowserHistory } from 'history';
import { State, rootReducer } from 'reducers';
const logger = createLogger();
export const history = createBrowserHistory();
export function configureStore(preloadedState?: State) {
const middlewares = [routerMiddleware(history), logger];
const middlewareEnhancer = applyMiddleware(...middlewares);
const store = createStore(rootReducer(history), preloadedState, middlewareEnhancer);
return store;
}
-
react-redux
のProvider
を使うことでReduxのStoreにアクセスできるようにします - また
Router
はConnectedRouter
に置き換えます
src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { ConnectedRouter } from 'connected-react-router';
import { configureStore, history } from './configureStore';
import routes from 'routes';
const store = configureStore();
ReactDOM.render(
<Provider store={store}>
<ConnectedRouter history={history}>{routes}</ConnectedRouter>
</Provider>,
document.getElementById('root'),
);
- これでReduxを使用する準備が整いました
- 関連Commitはこちら
Redux Hooksを使う
- それでは実際にReduxを使った実装に置き換えてみましょう
- 今回は
searchResult
changeSearchResult
をreduxに置き換えてみます - まずはGoogleBooks用のreducer
googleBooksReducer
を作成します- ここでもimmutable.jsのRecordで状態を管理します
-
GoogleBooksState
でこのreducerのモデルを定義し、reducerWithInitialState()
で初期値を定義します
src/reducers/googleBooks.ts
import { reducerWithInitialState } from 'typescript-fsa-reducers';
import { Record } from 'immutable';
import { VolumeList } from 'models/Volume';
export class GoogleBooksState extends Record<{
volumeList: VolumeList;
}>({
volumeList: new VolumeList(),
}) {}
export const googleBooksReducer = reducerWithInitialState(new GoogleBooksState());
- 次にGoogleBooks用のActionを
GoogleBooksActions
として定義します
src/actions/googleBooks
import actionCreatorFactory from 'typescript-fsa';
import { VolumeList } from 'models/Volume';
const actionCreator = actionCreatorFactory('GoogleBooks');
export const GoogleBooksActions = {
setVolumes: actionCreator<VolumeList>('setVolumes'),
};
- reducerに戻って
GoogleBooksActions.setVolumes
がdispatch
された時にstateを更新して返すようにします- callbackには現在のstateとActionの引数がpayloadとして渡ってきます
- stateは
GoogleBooksState
なので、このうちvolumeList
を新しい値に更新します - immutable.jsには
set
update
といった新しい値を返す関数が用意されているので、ドキュメントを参考に使い分けましょう
src/reducers/googleBooks.ts
import { GoogleBooksActions } from 'actions/googleBooks';
export class GoogleBooksState extends Record<{
volumeList: VolumeList;
}>({
volumeList: new VolumeList(),
}) {}
export const googleBooksReducer = reducerWithInitialState(new GoogleBooksState())
.case(GoogleBooksActions.setVolumes, (state, payload) => {
return state.set('volumeList', payload);
},
);
- 最後に、コンポーネントから上記のstateやActionを呼び出して使用するように置き換えてみましょう
- これまでは
react-redux
のconnect
でコンポーネントとreduxを結合していましたが、v7.1.0
よりhooksが使えるようになったため、hooksで記述しています -
useSelector
では任意のstateを呼び出すことができるようになりました -
useDispatch
でdispatchを生成することで、dispatch(GoogleBooksActions.setVolumes()
といったように任意のActionをdispatchできるようになりました
- これまでは
src/components/GoogleBooks/index.tsx
import { useDispatch, useSelector } from 'react-redux';
import { State } from 'reducers';
import { GoogleBooksActions } from 'actions/googleBooks';
export const GoogleBooks: React.FC = () => {
const [searchString, changeSearchString] = useState('');
const { volumeList } = useSelector((state: State) => ({ volumeList: state.googleBooks.volumeList }));
const dispatch = useDispatch();
const handleOnSearchButton = async (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
event.preventDefault();
const result = await searchGoogleBooks(searchString);
if (result.isSuccess) {
dispatch(GoogleBooksActions.setVolumes(VolumeList.fromResponse(result.data)));
} else {
window.alert(String(result.error));
}
};
return (
<Wrapper>
<Body>
<Title>Google Books 検索</Title>
<SearchForm>
<Input placeholder='検索ワードを入力してね!' onChange={event => changeSearchString(event.target.value)} />
<SearchButton onClick={event => handleOnSearchButton(event)} disabled={!searchString}>
検索
</SearchButton>
</SearchForm>
{volumeList.kind && <SearchResult volumeList={volumeList} />}
</Body>
</Wrapper>
);
};
- 今回のようなシンプルな場合はReduxの恩恵をほとんど感じませんが、コンポーネント内の記述が複雑になってくるとロジックをReduxに分離したことが活きてくるようになります
- 関連Commitはこちら
非同期処理をredux-sagaに置き換える
- さらに非同期処理を
redux-saga
を使うとより関心の分離を意識した設計にすることができます- 似たライブラリとして
redux-thunk
もありますが、redux-saga
と比較してネストが深くなってしまったり、Actionをそのまま扱いづらい、テストがし辛いなどの点でredux-saga
選定しています
- 似たライブラリとして
- 今回はAPIとの通信処理をredux-sagaに置き換えてみます
terminal
yarn add redux-saga
- まずは
rootSaga
を用意します
src/sagas/index.ts
import { all } from 'redux-saga/effects';
export const rootSaga = function* root() {
yield all([]);
};
-
configureStore
のmiddlewareに追加してreduxと合わせて使えるようにします
src/configureStore.ts
import { rootSaga } from 'sagas';
const sagaMiddleware = createSagaMiddleware();
export function configureStore(preloadedState?: State) {
const middlewares = [routerMiddleware(history), sagaMiddleware, logger];
const middlewareEnhancer = applyMiddleware(...middlewares);
const store = createStore(rootReducer(history), preloadedState, middlewareEnhancer);
sagaMiddleware.run(rootSaga);
return store;
}
- それでは、実際にredux-sagaで非同期処理を書いてみます
-
GoogleBooksSaga
では特定のActionがdispatchされた時に実行する関数を指定しています-
takeLatest
は連続で呼ばれた場合などで最新のものだけ実行する時に使います
-
-
function* getVolumes()
ではActionのpayloadを使ってsearchGoogleBooks()
を呼び、成功した場合のみGoogleBooksActions.setVolume()
をdispatchするようにしています
src/sagas/googleBooks.ts
import { put, takeLatest } from 'redux-saga/effects';
import axios from 'axios';
import { GoogleBooksActions } from 'actions/googleBooks';
import { VolumeList } from 'models/Volume';
const searchGoogleBooks = async (searchString: string) => {
const url = 'https://www.googleapis.com/books/v1/volumes';
const params = { q: searchString };
try {
const response = await axios.get(url, { params });
return { isSuccess: true, data: response.data, error: null };
} catch (error) {
return { isSuccess: false, data: null, error };
}
};
function* getVolumes(action: ReturnType<typeof GoogleBooksActions.getVolumes>) {
const searchString = action.payload;
const result = yield searchGoogleBooks(searchString);
if (result.isSuccess) {
yield put(GoogleBooksActions.setVolumes(VolumeList.fromResponse(result.data)));
} else {
window.alert(String(result.error));
}
}
export function* GoogleBooksSaga() {
yield takeLatest(GoogleBooksActions.getVolumes, getVolumes);
}
- 先ほどの
rootSaga
にGoogleBooksSaga
を追加します
src/sagas/index.ts
import { all, fork } from 'redux-saga/effects';
import { GoogleBooksSaga } from 'sagas/googleBooks';
export const rootSaga = function* root() {
yield all([fork(GoogleBooksSaga)]);
};
- 最後に、コンポーネントの処理を置き換えます
- 処理はActionを呼ぶだけになったため、コンポーネントがかなりスッキリしました
src/components/GoogleBooks/index.tsx
export const GoogleBooks: React.FC = () => {
const [searchString, changeSearchString] = useState('');
const { volumeList } = useSelector((state: State) => ({ volumeList: state.googleBooks.volumeList }));
const dispatch = useDispatch();
return (
<Wrapper>
<Body>
<Title>Google Books 検索</Title>
<SearchForm>
<Input placeholder='検索ワードを入力してね!' onChange={event => changeSearchString(event.target.value)} />
<SearchButton
onClick={event => {
event.preventDefault();
dispatch(GoogleBooksActions.getVolumes(searchString));
}}
disabled={!searchString}
>
検索
</SearchButton>
</SearchForm>
{volumeList.kind && <SearchResult volumeList={volumeList} />}
</Body>
</Wrapper>
);
};
- このように、reduxとredux-sagaを使うことでコンポーネントの記述をほぼrenderだけにすることができました
- 関連Commitはこちら
API Clientを作成する
- APIとの通信処理は似たような記述になるのでAPI Clientとして処理を分けてみましょう
- まずはベースをApiClientとして作ってみます
src/apiClient/index.ts
import axios, { AxiosError, AxiosInstance, AxiosRequestConfig } from 'axios';
import { AxiosResponse } from 'types/responses/axios';
export class ApiClient {
axiosInstance: AxiosInstance;
constructor(baseURL = '') {
this.axiosInstance = axios.create({ baseURL });
this.axiosInstance.interceptors.request.use(
async (config: AxiosRequestConfig) => {
return config;
},
(err: AxiosError) => {
return Promise.reject(err);
},
);
}
async get<T = object>(path: string, params: object = {}): Promise<AxiosResponse<T>> {
try {
const result = await this.axiosInstance.get(path, { params });
return this.createSuccessPromise<T>(result.data);
} catch (e) {
return this.createFailurePromise<T>(e);
}
}
async post<T = object>(path: string, params: object = {}): Promise<AxiosResponse<T>> {
try {
const result = await this.axiosInstance.post<T>(path, params);
return this.createSuccessPromise<T>(result.data);
} catch (e) {
return this.createFailurePromise<T>(e);
}
}
async put<T = object>(path: string, params: object = {}): Promise<AxiosResponse<T>> {
try {
const result = await this.axiosInstance.put<T>(path, params);
return this.createSuccessPromise<T>(result.data);
} catch (e) {
return this.createFailurePromise<T>(e);
}
}
async delete<T = object>(path: string): Promise<AxiosResponse<T>> {
try {
const result = await this.axiosInstance.delete(path);
return this.createSuccessPromise<T>(result.data);
} catch (e) {
return this.createFailurePromise<T>(e);
}
}
async patch<T = object>(path: string, params: object = {}): Promise<AxiosResponse<T>> {
try {
const result = await this.axiosInstance.patch<T>(path, params);
return this.createSuccessPromise<T>(result.data);
} catch (e) {
return this.createFailurePromise<T>(e);
}
}
private createSuccessPromise<T>(data: T): Promise<AxiosResponse<T>> {
return Promise.resolve<AxiosResponse<T>>({ data, isSuccess: true });
}
private createFailurePromise<T>(error: AxiosError): Promise<AxiosResponse<T>> {
return Promise.resolve<AxiosResponse<T>>({ error, isSuccess: false });
}
}
- 上記を使用してGoogle Books APIのVolumeを取得するAPI Clientを作ると次のようになります
src/apiClient/googleBooks.ts
import { ApiClient } from 'apiClient';
import { JSObject } from 'types/Common';
const baseURL = 'https://www.googleapis.com/books/v1';
const apiClient = new ApiClient(baseURL);
const VOLUME_PATH = '/volumes';
export class VolumeApi {
static get(params: JSObject): Promise<{}> {
return apiClient.get(VOLUME_PATH, params);
}
}
- 先ほどのsagaを上記を使って置き換えてみましょう
src/sagas/googleBooks.ts
import { put, takeLatest } from 'redux-saga/effects';
import { GoogleBooksActions } from 'actions/googleBooks';
import { VolumeList } from 'models/Volume';
import { VolumeApi } from 'apiClient/googleBooks';
function* getVolumes(action: ReturnType<typeof GoogleBooksActions.getVolumes>) {
const searchString = action.payload;
const params = { q: searchString };
const response = yield VolumeApi.get(params);
if (response.isSuccess) {
yield put(GoogleBooksActions.setVolumes(VolumeList.fromResponse(response.data)));
}
}
-
endpointやmethodごとにAPI Clientを作成することが簡単にできるようになりました
-
関連Commit
-
スタイルも整えると次のようになります
- GitHubのリポジトリはこちら
おわりに
- Reactだけでアプリケーションを作成することもできますが、複数人で開発したりコードが肥大化してきた時には上記のように型安全や関心の分離を意識することが重要になってくるはずなので、参考にしてもらえれば幸いです