ドラゴンクエストⅢみたいなタイトルになってしまいましたが,わりと内容は真面目です。最近,仕事で本格的にReactとReduxを使う機会があったので,お手本構成プロジェクトを自分なりに作ってみました。備忘録がてらQiitaにも内容を残しておきたいと思います。今回の目的は以下の通りです。
- TypeScriptでReactとReduxを扱う
- (テストを考慮して)ReactとReduxは疎結合にする→Container/Presenter構成
- 非同期処理も考慮する→Redux Thunk導入
- ユニットテストを書く
何はなくともcreate-react-app
手抜き目的でボイラープレートを使います。だから,いつまでたってもwebpackが書けない。
npm install -g create-react-app
TypeScriptモードでプロジェクトを生成しておきます。めちゃ便利。
create-react-app sample --typescript
生成されるコードをクラス仕様に変更
ボイラープレートが生成するコードは,クラス仕様になっていないので変更しておきます。
interface AppProperty {}
interface AppState {}
/**
* 生まれ変わったAppコンポーネント
*/
export default class App<AppState, AppProperty> extends React.Component {
render() {
return (
<div className="App">
</div>
)
}
}
Reduxを導入
Reduxを導入します。ActionCreatorやReducerを作るのが面倒なので補助ライブラリも入れておきたいですね。
- typescript-fsa → ActionCreatorを簡単に生成してくださる素晴らしい方
- typescript-fsa-reducers → Reducerを簡単に作成してくださる素晴らしい方
npm i -S react-redux redux typescript-fsa typescript-fsa-reducers
あと,忘れずにRedux用のTypeScript型ファイルも入れておきます。
npm i -D @types/react-redux
redux thunkを導入
非同期処理もやっていくのでthunkもこのタイミングで導入します。TypeScriptの型も忘れずに!
npm i -S redux-thunk
npm i -D @types/redux-thunk
Actionの作成
普通のAction
まずはObjectを返却する普通のActionです。
import { actionCreatorFactory } from 'typescript-fsa';
interface AdviceInfo {
message: string;
}
const actionCreator = actionCreatorFactory();
//普通のAction
export const fetchAdvice = actionCreator<AdviceInfo>('FETCH_ADVICE');
typescript-fsaのおかげで簡潔に書けますね。
非同期処理のAction
続いて非同期パターン。redux-thunkを使うので関数で書いていきます。今回はAxiosで格言?をランダムで返してくれるOpenなAPIを呼んでみました。
ちなみに,middleware(redux-thunk)の適用を忘れると「Actionはオブジェクトしか返せねぇよ!!!(怒)」というエラーが出るので気をつけましょう。
//非同期処理を行うAction
export const fetchAdviceAsync = () => {
return async (dispatch: Dispatch<Action>, getState: () => AppState) => {
try {
const response = await axios.get('https://api.adviceslip.com/advice');
const result: AdviceInfo = {
message: response.data.slip.advice,
};
dispatch(fetchAdvice(result));
} catch {
//勇者よ,忘れず例外処理をやるのです
//例外通知用の同期Actionを作るのもオススメです
}
};
};
AppStateはStoreで定義します。念の為。
Reducerの作成
今度はActionを受けてStateの操作を行うReducerを作っていきます。typescript-fsa-reducersが大活躍です。
import { reducerWithInitialState } from 'typescript-fsa-reducers';
import { fetchAdvice } from '../actions/adviceAction';
/**
* ReducerのState型
*/
export interface AdviceState {
message: string;
}
/**
* ReducerのState初期値
*/
const initialState: AdviceState = {
message: '',
};
/**
* Actionを受け取った時のState操作
*/
export const adviceReducer = reducerWithInitialState(initialState).case(
fetchAdvice,
(state, payload) => {
return {
...state,
message: payload.message,
};
}
);
Storeの作成
ここは普通……ですね! Thunkをmiddlewareとして適用するのを忘れずに!
/**
* アプリケーション全体のStore定義
*/
export interface AppState {
advice: AdviceState;
}
const store = createStore(
combineReducers<AppState>({
advice: adviceReducer,
}),
applyMiddleware(thunk)
);
export default store;
index.tsxの修正
ReduxとReactを連携させます。
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
);
Containerの作成
ReduxのStoreとView(App.tsx)を紐付けるContainerを作成します。ContainerはView単位に作成していきます。面倒ですが,これもテストをしやすくするためです。
/**
* StateとPropertyをBindするよ
*/
const mapStateToProps = (state: AppState, props: AppProperty) => {
return {
message: state.advice.message,
};
};
/**
* ActionとPropertyをBindするよ
*/
const mapDispatchToProps = (dispatch: any, props: AppProperty) => {
return {
fetchAdvice: () => {
dispatch(fetchAdviceAsync());
},
};
};
const AppContainer = connect(
mapStateToProps,
mapDispatchToProps
)(App);
export default AppContainer;
App.tsxを微修正
ContainerとAppはProperty経由でやり取りするので,AppのProperty定義(interface)をそれに合わせて修正します。
/**
* Containerと連携するよ
*/
interface AppProperty {
message?: string;
fetchAdvice?: () => void;
}
interface AppState {}
/**
* 生まれ変わったAppコンポーネント
*/
export default class App<AppState, AppProperty> extends React.Component {
render() {
return (
<div className="App">
<h1>{this.props.message}</h1>
<button onClick="{this.props.fetchAdvice}">押せ!力の限り</push>
</div>
)
}
}
各属性をOptional型(nullかもしれない)にしておかないとContainerでエラーが出ます。
const AppContainer = connect(
mapStateToProps,
mapDispatchToProps
)(App);
ここでPropertyに何も渡さずにAppを使うからです。
index.tsxを微修正
Containerを作ったことでAppを直接指定する必要がなくなりました。
ReactDOM.render(
<Provider store={store}>
<AppContainer />
</Provider>,
document.getElementById('root')
);
一旦動かしてみる
アプリ自体はこれで完成です。
npm run start
一旦動かしてみましょう。
ユニットテストを書く
非同期Actionのテストコードを書くところでハマったので,その部分だけ補足的に書いておきます。テスト対象は↓のこいつ。
//非同期処理を行うAction
export const fetchAdviceAsync = () => {
return async (dispatch: Dispatch<Action>, getState: () => AppState) => {
try {
const response = await axios.get('https://api.adviceslip.com/advice');
const result: AdviceInfo = {
message: response.data.slip.advice,
};
dispatch(fetchAdvice(result));
} catch {
//勇者よ,忘れず例外処理をやるのです
//例外通知用の同期Actionを作るのもオススメです
}
};
};
redux-mock-storeの導入
ActionはStoreに依存しているため,依存関係を排除したプレーンなテストを実施したいところです。そのためにredux-mock-storeを使います。
npm i -D redux-mock-store
Actionのテストコードを実装
redux-mock-storeを使ってモック用Storeを作成します。その際にDispatchの型をredux thunk準拠(ThunkDispatch)にしておくことが重要です。この一手間を省くと型エラーが発生してActionを呼べなくなります。
describe('AdviceAction', () => {
it('非同期処理', async () => {
//この一手間が明暗を分ける
type DispatchExts = ThunkDispatch<AppState, void, AnyAction>;
const mockStore = configureStore<AppState, DispatchExts>([thunk]);
const store = mockStore();
await store.dispatch(fetchAdviceAsync());
expect(store.getActions()[0]).toEqual({
payload: {
message: 'mocked',
},
type: 'FETCH_ADVICE',
});
});
});
オマケ: Axiosのモック
jestを使えばAxiosのモックも出来るので,よりベターです。
jest.mock('axios');
import axios from 'axios';
(axios.get as any).mockResolvedValue({ data: { slip: { advice: 'mocked' } } });
describe('AdviceAction', () => {
it('非同期処理', async () => {
//以下略