LoginSignup
2
2

More than 1 year has passed since last update.

【React】React+ReduxのプロジェクトにTypeScriptを導入してみた

Last updated at Posted at 2021-05-22

はじめに

これまでTypeScriptの基礎やReactプロジェクトにTypeScriptを導入する方法について学習してきたのですが、ReduxへのTypeScriptの導入については現場でReduxを使っていないこともありタッチしていませんでした。
しかし、今後Redux+TypeScriptの案件に携わる可能性がないともいいきれないので、この機に学んでみることにしました。

プロジェクト設計

NPMのAPIでパッケージ名を検索する簡単なプロジェクトをReact+Redux+TypeScriptで作成しました。
スクリーンショット 2021-05-20 22.08.13.png

Store設計

Reduxのプロジェクトを作成する際に一番最初にすべきことがStoreの設計です。
これはTypeScriptを導入する際にはさらに重要なステップとなります。

今回のケースでは、repositoriesというReducerをつくり、data(検索結果のデータ)、loading(ロード状態の判定)、error(エラーメッセージ)のプロパティをもったオブジェクトがstateとして返ってくるような構成となります。
このとき、packageがTypeScriptでは予約語にあたるので、packagesのことをrepositoriesとしています。
スクリーンショット 2021-05-21 7.11.13.png

Redux設計

Storeの設計ができたら、次にAction CreatorやActionを加えた全体の設計を考えます。

今回はAction Creator1つに対して3つのActionを用意します。
SearchRepositoriesはロード、SearchRepositoriesSuccessは検索結果、SearchRepositoriesErrorはエラーを返したいときにDispatchするActionとなります。
スクリーンショット 2021-05-21 7.52.50.png

React/Redux連携

ReduxではReducer、Action Creator、Middlewareが必要になるため、それぞれのフォルダやファイルがつくられます。
ReactとReduxを連携する際、Reactのコンポーネントでこれらのファイルをインポートすることになるのですが、インポート先がバラバラなため若干複雑な構成になってしまいます。

ここでRedux側にindex.tsというエンドポイントをつくり、ここからReduxの各フォルダにアクセスできるようにします。
こうすることで、ReactとReduxをシンプルに連携することができます。

実装

プロジェクト設計が終わったら実装に入ります。
Redux側とReact側で分けて説明します。

Reduxの実装

Reducer, Action Creator, エンドポイントの作成について主に説明します。

Reducerの作成

SearchRepositories, SearchRepositoriesSuccess, SearchRepositoriesErrorの各Actionに対して、ロード中、検索結果のデータ、エラーの状態(State)を返すようなReducerを作成します。
例えば、ロード中であれば{ loading: true, error: null, data: [] }というStateを返し、検索が成功したら{ loading: false, error: null, data: action.payload }のようにデータの入ったStateを返すようにします。

RepositoriesList.tsx
import { ActionType } from '../action-types';
import { Action } from '../actions';

interface RepositoriesState {
  loading: boolean;
  error: string | null;
  data: string[];
}

const initialState = {
  loading: false,
  error: null,
  data: [],
};

const reducer = (
  state: RepositoriesState = initialState,
  action: Action
): RepositoriesState => {
  switch (action.type) {
    case ActionType.SEARCH_REPOSITORIES:
      return { loading: true, error: null, data: [] };
    case ActionType.SEARCH_REPOSITORIES_SUCCESS:
      return { loading: false, error: null, data: action.payload };
    case ActionType.SEARCH_REPOSITORIES_ERROR:
      return { loading: false, error: action.payload, data: [] };
    default:
      return state;
  }
};

export default reducer;

また、ActionのTypeについては、Reducer以外にもActionの型定義やAction Creatorなどの複数のファイルで使用するため、src/action-types/index.tsで管理します。

index.ts
export enum ActionType {
  SEARCH_REPOSITORIES = 'search_repositories',
  SEARCH_REPOSITORIES_SUCCESS = 'search_repositories_success',
  SEARCH_REPOSITORIES_ERROR = 'search_repositories_error',
}

repositoriesReducer.tsと同じ階層にindex.tsを作成し、作成したReducer(repositoriesReducer)をcombineReducersでラッピングします。
今後新しくReducerを作成したとしても、CombineReducerに登録するだけで1つのまとまったReducerとして扱うことができます。

index.ts
import { combineReducers } from 'redux';
import repositoriesReducer from './repositoriesReducer';

const reducers = combineReducers({
  repositories: repositoriesReducer,
});

export default reducers;

export type RootState = ReturnType<typeof reducers>;

Reducerの型定義

Recucerは引数にStateとAction、戻り値に最新のStateをもつため、それぞれに対して型定義を行う必要があります。

const reducer = (
  state: RepositoriesState = initialState,
  action: Action
): RepositoriesState => {

まず、Stateの型は以下のように定義します。
loadingはロード中の可否(true/false)を返すためbooleandataは空の場合も含めたデータのリストを返すためstring[]errorはエラーメッセージを返す場合と返さない場合があるためstring | nullのUnion型で定義します。

interface RepositoriesState {
  loading: boolean;
  error: string | null;
  data: string[];
}

試しにcase ActionType.SEARCH_REPOSITORIES:の戻り値のStateの一部をloading: nullとしてみると、「nullはboolean型じゃありませんよ!」とちゃんと怒ってくれます。

次に、Actionの型については、3つのAction(SearchRepositoriesAction, SearchRepositoriesSuccessAction, SearchRepositoriesErrorAction)のUnion型として定義します。

SearchRepositoriesSuccessActionとSearchRepositoriesErrorActionについては、検索結果やエラーメッセージを返すためpayloadをもちます。
検索結果はリストのためstring[]、エラーメッセージはstringとして定義します。

index.ts
import { ActionType } from '../action-types';

interface SearchRepositoriesAction {
  type: ActionType.SEARCH_REPOSITORIES;
}

interface SearchRepositoriesSuccessAction {
  type: ActionType.SEARCH_REPOSITORIES_SUCCESS;
  payload: string[];
}

interface SearchRepositoriesErrorAction {
  type: ActionType.SEARCH_REPOSITORIES_ERROR;
  payload: string;
}

export type Action =
  | SearchRepositoriesAction
  | SearchRepositoriesSuccessAction
  | SearchRepositoriesErrorAction;

Action Creatorの作成

非同期でDispatch(ActionをReducerに渡すもの)を返すようなAction Creator(searchRepositories)を作成します。
一見中身が複雑ですが、処理の流れは以下のようにシンプルです。

  1. SearchRepositoriesActionをDispatch(Reducerでロード中のStateに更新)
  2. 検索ワードtermをパラメータとして渡しnpm APIからデータを取得
  3. 【データ取得に成功時】SearchRepositoriesActionSuccessをDispatch(Reducerで検索結果のStateに更新)
  4. 【データ取得に失敗時】SearchRepositoriesActionErrorをDispatch(ReducerでエラーメッセージのStateに更新)
index.ts
import axios from 'axios';
import { Dispatch } from 'redux';
import { ActionType } from '../action-types';
import { Action } from '../actions';

export const searchRepositories = (term: string) => {
  return async (dispatch: Dispatch<Action>) => {
    //SearchRepositoriesActionのDispatch(ロード中のStateに更新)
    dispatch({
      type: ActionType.SEARCH_REPOSITORIES,
    });

    try {
      //非同期でnpmのAPIからデータ取得
      const { data } = await axios.get(
        'https://registry.npmjs.org/-/v1/search',
        {
          params: {
            text: term,
          },
        }
      );

      //取得データかパッケージ名のみを配列で返す
      const names = data.objects.map((result: any) => {
        return result.package.name;
      });

      //SearchRepositoriesActionSuccessのDispatch(検索結果のStateに更新)
      dispatch({
        type: ActionType.SEARCH_REPOSITORIES_SUCCESS,
        payload: names,
      });
    } catch (err) { //データ取得に失敗した場合
      //SearchRepositoriesActionErrorのDispatch(エラーメッセージのStateに更新)
      dispatch({
        type: ActionType.SEARCH_REPOSITORIES_ERROR,
        payload: err.message,
      });
    }
  };
};

Action Creatorの型定義

dispatchの型定義をdispatch: Dispatch<Action>のように行います。
ジェネリクスとしてsrc/actions/index.tsで定義したActionの型を渡しています。

export type Action =
  | SearchRepositoriesAction
  | SearchRepositoriesSuccessAction
  | SearchRepositoriesErrorAction;

こうすることで、型定義していないActionを誤って記述してしまったときにWarningを出してくれるようになります。

Redux側のエンドポイント(index.ts)の作成

React/Redux連携の部分で説明した、Redux側のエンドポイントとなるファイルを以下のように作成します。

index.ts
export * from './store';
export * as actionCreators from './action-creators';
export * from './reducers';

これでReact側からこのファイルにアクセスするだけで、StoreとAction CreatorとReducerすべてにアクセスすることができるようになります。
ちなみにStoreの中身は以下のようになっています。

store.ts
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import reducers from './reducers';

export const store = createStore(reducers, {}, applyMiddleware(thunk));

React(表示側)の実装

Reduxの実装ができたので、Reactのコンポーネント側でReduxとの接続を行っていきます。

RepositoriesList.tsxの作成

検索文字の入力と送信を行って、検索結果を表示するような画面を作成します。
useActions()useTypedSelector()というカスタムフックがありますが、これらを使用することでそれぞれStoreのStateの更新、取得を行っています。

Stateの更新と検索結果表示の流れは以下のようになります。

  1. Inputで入力した文字(term)をボタンを押したときにformで送信
  2. onSubmit内でuseActions()で定義したsearchRepositories(term)を実行(Stateの更新)
  3. Stateが更新されることでuseTypedSelector()dataも更新
  4. 検索結果を表示
RepositoriesList.tsx
import { useState } from 'react';
import { useActions } from '../hooks/useActions';
import { useTypedSelector } from '../hooks/useTypedSelector';

const RepositoriesList: React.FC = () => {
  const [term, setTerm] = useState('');
  const { searchRepositories } = useActions();
  const { data, error, loading } = useTypedSelector(
    (state) => state.repositories
  );

  const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    searchRepositories(term);
  };

  return (
    <div>
      <form onSubmit={onSubmit}>
        <input value={term} onChange={(e) => setTerm(e.target.value)} />
        <button>Search</button>
        {error && <h3>{error}</h3>}
        {loading && !loading && data}
        {!error && !loading && data.map((name) => <div key={name}>{name}</div>)}
      </form>
    </div>
  );
};

export default RepositoriesList;

useActions()の作成

useActions()useDispatch()bindActionCreatorsによって、作成したActionをReactコンポーネント内でDispatchできるようにしたカスタムフックです。

useDispatch()を使用するとmapDispatchToPropsやconnect関数を書かずにStoreとの接続を行い、ActionをDispatchできるようになります。
useDispatch()で定義したdispatchbindActionCreatorsでAction Creatorと接続してあげることで、作成したActionをReactコンポーネント内でDispatchできるようになります。

useActions.ts
import { useDispatch } from 'react-redux';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../state';

export const useActions = () => {
  const dispatch = useDispatch();

  return bindActionCreators(actionCreators, dispatch);
};

useTypedSelector()の作成

useTypedSelector()useSelectorに型定義を行ったカスタムフックです。

useSelector()を使用するとmapStateToPropsやconnect関数を書かずにStoreとの接続を行い、Reactコンポーネント内でStoreのStateを使えるようになります。

useTypedSelector.ts
import { useSelector, TypedUseSelectorHook } from 'react-redux';
import { RootState } from '../state';

export const useTypedSelector: TypedUseSelectorHook<RootState> = useSelector;

useTypedSelectorの型定義にはTypedUseSelectorHook<T>を使用し、ジェネリクスとしてRootStateを渡しています。
このRootStatesrc/reducers/index.ts内で定義したReturnType<typeof reducers>です。

export type RootState = ReturnType<typeof reducers>;

RepositoriesList.tsx内のuseTypedSelectorにカーソルをあてると、以下のように型定義されていることを確認することができます。

カスタムフックの作成については、以下の記事をあわせて参考にしていただければと思います。

App.tsxの作成

あとはApp.tsxProviderをラッピングしてあげるだけです。

App.tsx
import { Provider } from 'react-redux';
import { store } from '../state';
import RepositoriesList from './RepositoriesList';

const App = () => {
  return (
    <Provider store={store}>
      <div>
        <h1>Search For a Package</h1>
        <RepositoriesList />
      </div>
    </Provider>
  );
};

export default App;

最終的なsrcの構成は以下のようになります。

参考資料

2
2
0

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
2
2