64
55

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

PatheeAdvent Calendar 2019

Day 11

React + Redux + TypeScript でモダンなwebアプリを作るチュートリアルを考えてみた②

Last updated at Posted at 2020-01-05

:star: はじめに

:pencil: GoogleBooksAPIを試してみる

developers.google.com_books_docs_v1_reference_volumes_list_apix_params=%7B%22q%22%3A%22%E3%82%B8%E3%83%A7%E3%82%B8%E3%83%A7%22%7D(Laptop with HiDPI screen).png

:pencil: 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のディベロッパーツールなどでレスポンスが正常に返ってきていることを確認できます
スクリーンショット 2019-12-09 23.07.42.png
  • レスポンスを表示するように変更しましょう
  • useState でレスポンスを保存するstate searchResult を新しく定義しています
  • 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;
  }
`;

  • 次のように検索結果を表示できるようになりました

localhost_3000_otameshi_(Laptop with HiDPI screen).png

:pencil: immutable.jsでレスポンスをモデル化する

terminal
# TypeScriptに対応するためv4を使用する
yarn add immutable@^4.0.0-rc.12

yarn add dayjs
  • Google Books APIのリファレンスを参考にVolumeモデルを作成します

  • 今回はレスポンスと同じ構造のモデルをimmutable.jsRecordで作成しています

    • 各Recordに fromResponse というレスポンスからモデルを生成するstaticメソッドを作っています
    • ネストする場合、Mapを使うと任意のkeyを追加できてしまうため、ここでもRecordを使うようにしています
    • 配列の場合はListを使うようにしています
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);
  }
}
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);
  }
}

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

:pencil: 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-reduxProvider を使うことでReduxのStoreにアクセスできるようにします
  • また RouterConnectedRouter に置き換えます
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'),
);

:pencil: 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.setVolumesdispatchされた時に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-reduxconnect でコンポーネントと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>
  );
};

:pencil: 非同期処理を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);
}
  • 先ほどのrootSagaGoogleBooksSagaを追加します
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>
  );
};

:pencil: 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)));
  }
}

localhost_3000_google_books(Laptop with HiDPI screen).png

:star: おわりに

  • Reactだけでアプリケーションを作成することもできますが、複数人で開発したりコードが肥大化してきた時には上記のように型安全や関心の分離を意識することが重要になってくるはずなので、参考にしてもらえれば幸いです
64
55
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
64
55

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?