とりあえず関東最速でReactアプリをTypeScriptで作るためのレシピです
注1: このサンプルではReduxを使いますが、もしImmutabilityを必要としないのであればUnduxを代替に検討してみてください! Reduxは関数型エッセンスを守るために非常にファイル数が多くなります。 →【Reduxに疲れた人のための】Undux入門
注2: React+Reduxはデフォルトの最小構成でもファイル数が多く複雑、かつTypeScriptで型を付けても完全に実行時エラーをゼロにするのは不可能です。よりアプリケーションの質を高めるためには、Reduxの起源となったElmの利用を検討してみてください→関東最速でElm+JSなアプリの開発環境を作る
create-react-app
最速で作るにはcreate-react-app一択です。--typescript
オプションが利用できるので、それを使います
$ npx create-react-app ts-react-app --typescript
redux
まずreduxをインストール
$ npm install --save redux react-redux
型定義ファイルを入れるのも忘れずに
$ npm install -D @types/react-redux
typescript-fsa
最速で作るにあたってReduxのボイラープレート・コードを書くのはダルすぎます。
なのでtypescript-fsaを入れましょう。
$ npm install --save typescript-fsa typescript-fsa-reducers
これを使うことでFSA(Flux Standard Action)1にも準拠できます。楽ですね。
さて早速コードを書いていきましょう
Action実装
src/actions
でディレクトリを切ってこんな感じでActionを定義
import actionCreatorFactory from 'typescript-fsa';
const actionCreator = actionCreatorFactory();
export const hogeActions = {
updateName: actionCreator<string>('ACTIONS_UPDATE_NAME'),
updateEmail: actionCreator<string>('ACTIONS_UPDATE_EMAIL')
};
Reducer実装
src/states
でディレクトリを切ってこんな感じでReducerを定義
import { reducerWithInitialState } from 'typescript-fsa-reducers';
import { hogeActions } from '../actions/hogeActions';
export interface HogeState {
name: string;
email: string;
}
const initialState: HogeState = {
name: '',
email: ''
};
export const hogeReducer = reducerWithInitialState(initialState)
.case(hogeActions.updateName, (state, name) => {
return Object.assign({}, state, { name });
})
.case(hogeActions.updateEmail, (state, email) => {
return Object.assign({}, state, { email });
});
Store実装
store.ts
みたいなファイルをsrc
直下にでも置いて、createStore
でストアを定義。
import { createStore, combineReducers } from 'redux';
import { hogeReducer, HogeState } from './states/hogeState';
export type AppState = {
hoge: HogeState
};
const store = createStore(
combineReducers<AppState>({
hoge: hogeReducer
})
);
export default store;
コンポーネント実装
今回はPresentational/Containerコンポーネント2のパターンで実装を進めます。
PresentatonalとContainerは責務の観点から粗結合性を担保するためにビューをふたつのレイヤに分けるものです。具体的には以下のような違いがあります。
Container | Presentational | |
---|---|---|
役割 | 「振る舞い」を実装 | 「見た目」を実装 |
状態 | 持つ | 持たない |
Reduxへの依存 | ある | ない |
すなわち、ContainerでReduxとの依存(ステートの状態やアクションの実行)などをラップし、ComponentはContainerによってラップされた振る舞いのみを知るだけでよくなります。
Container実装
src/containers
でディレクトリを切ってこんな感じで実装
import { Action } from 'typescript-fsa';
import { Dispatch } from 'redux';
import { connect } from 'react-redux';
import { AppState } from '../store';
import { hogeActions } from '../actions/hogeActions';
import { HogeComponent } from '../components/hogeComponent';
export interface HogeActions {
updateName: (v: string) => Action<string>;
updateEmail: (v: string) => Action<string>;
}
function mapDispatchToProps(dispatch: Dispatch<Action<string>>) {
return {
updateName: (v: string) => dispatch(hogeActions.updateName(v)),
updateEmail: (v: string) => dispatch(hogeActions.updateEmail(v))
};
}
function mapStateToProps(appState: AppState) {
return Object.assign({}, appState.hoge);
}
export default connect(mapStateToProps, mapDispatchToProps)(HogeComponent);
mapDispatchToProps
の中でbindActionCreators
を使うのをやめましょう。それを使ってしまっては直接コンポーネントのなかでdispatch
を呼んでいるのと同じで、Containerレイヤを置く意味がありません。もしそうしたいのであれば、Mobxなどを使ったほうがよいです。
Component実装
Presentationalなコンポーネントには状態を与えるのをやめましょう。ロジックはContainer側で定義すべきです。
コードはsrc/components
みたいなディレクトリを切ってそこへ入れましょう。ここは原則として状態を持たない場所なので、React.SFC
を使うのが適切です。
import * as React from 'react';
import { HogeState } from '../states/hogeState';
import { HogeActions } from '../containers/hogeContainer';
interface OwnProps {}
type HogeProps = OwnProps & HogeState & HogeActions;
export const HogeComponent: React.SFC<HogeProps> = (props: HogeProps) => {
return (
<div>
<div className="field">
<input
type="text"
placeholder="name"
value={props.name}
onChange={(e) => props.updateName(e.target.value)}
/>
</div>
<div className="field">
<input
type="email"
placeholder="email"
value={props.email}
onChange={(e) => props.updateEmail(e.target.value)}
/>
</div>
</div>
);
};
App.tsx
への追加
src
以下にApp.tsx
というファイルがあるので、そこでHogeComponent
を表示するようにしましょう
import * as React from 'react';
import HogeContainer from '../src/containers/hogeContainer';
class App extends React.Component {
render() {
return (
<div className="App">
<HogeContainer />
</div>
);
}
}
export default App;
index.tsx
の更新
src
以下にあるindex.tsx
が実際にReactのrootコンポーネントをマウントしているところです。
最後にここでstoreを使うように書き換えましょう。
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import Store from './store';
import App from './App';
import registerServiceWorker from './registerServiceWorker';
import './index.css';
ReactDOM.render(
<Provider store={Store}>
<App />
</Provider>,
document.getElementById('root') as HTMLElement
);
registerServiceWorker();
起動
あとは以下のコマンドで起動します。
$ npm start
(tslintでエラーが出る場合にはコメント欄にある@manomuさんのコメントを参考にしてみてください)
【発展編】非同期処理したい
以上であらかたのアプリケーションの骨組みは作れたものの、もっとちゃんとしたアプリケーション開発ではAPIを叩いたりしますよね。
typescript-fsaを使っているので、Actionは単なるステート更新の起点としての責務に徹し、APIを叩く処理はコンポーネントに対して振る舞いを定義するContainerの中3に置きます。
非同期Actionの定義
typescript-fsaに用意されているactionCreator.async
というメソッドを使うと、内部でSTARTED
, DONE
, FAILED
というtypeサフィックスをつけたActionの定義を行ってくれます。
import actionCreatorFactory, { ActionCreator, Success, Failure } from 'typescript-fsa';
const actionCreator = actionCreatorFactory();
const submit =
actionCreator.async<{}, {}, {}>('ACTIONS_SUBMIT')
export interface HogeAsyncActions {
startLogin: ActionCreator<{}>;
failedLogin: ActionCreator<Failure<{}, {}>>;
doneLogin: ActionCreator<Success<{}, {}>>;
}
export const hogeAsyncActions = {
startLogin: submit.started,
failedLogin: submit.failed,
doneLogin: submit.done
}
Reducerの定義
ReducerはActionで定義された3つのステートを処理するcaseを作るだけです。
import { reducerWithInitialState } from 'typescript-fsa-reducers';
import { hogeAsyncActions } from '../actions/hogeAsyncActions';
// ...
export const hogeAsyncReducers = reducerWithInitialState(initialState)
.case(hogeAsyncActions.startLogin, (state) => {
// ...
})
.case(hogeAsyncActions.failedLogin, (state) => {
// ...
})
.case(hogeAsyncActions.doneLogin, (state) => {
// ...
})
ContainerでのAPIアクセス処理の実装
上ですでに実装したContainer内のmapDispatchToPropsで以下のようにAPIアクセスを行う一連の処理を実装します。
// ...
import { hogeAsyncActions } from '../actions/hogeAsyncActions';
// ...
function mapDispatchToProps(dispatch: Dispatch<void>) {
return {
submit() {
dispatch(hogeAsyncActions.startLogin({}));
callYouOwnAPI()
.then(() => {
dispatch(hogeAsyncActions.doneLogin({
params: {}, result: {}
}));
})
.catch(() => {
dispatch(hogeAsyncActions.failedLogin({
params: {}, error: {}
}));
});
},
// ...
};
}
あとはこれをコンポーネントの中から呼び出すだけです。
tslintの設定
tslint.json
に以下を追加
"rules": {
"interface-name": [
false
],
"object-literal-sort-keys": false,
"ordered-imports": false,
"no-empty-interface": false,
"interface-over-type-literal": false
}
-
https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0 ↩
-
いやいや、そこはredux-sagaっしょ! という人もいるかもしれませんが、ここでは省略します。 ↩