はじめに
Reactを使うにあたって、Reduxにはさっさと入門しておいたほうがいいと思います。
フロントを始めた時に、こんなドキュメントがあればハマらなかっただろうな…と思うことを備忘します。
ただ、こんな記事を書いておいてなんですが、よくわからなければ公式ドキュメントとエラーメッセージをよく読むのが解決への近道です。
前提条件
https://qiita.com/IgnorantCoder/items/d26083d9f886ca66d4ae
が終わっている。
つまり、TypeScriptがビルドできるようになっている。
目指す姿
- Reduxの公式のTodo ListアプリをTypeScriptで書く
必要なモジュールをインストールする
それでは必要なモジュールをインストールします。
Redux
fluxの実装のひとつで、Stateを一括で管理してくれるやつ。
こいつのおかげで、あそこのcomponentの値変えてどうのこうとのとかややこしいことする必要がない。
Reduxは型情報を持ってるので@typesは不要です。
npm install --save redux
React Redux
ReactのcomponentとReduxのcontainerを繋いでくれる凄いやつ。
npm install --save react-redux
npm install --save-dev @types/react-redux
TodoList書いていく
ここからはExampleをなぞってゆく。
ただ、ファイルの構成が例になぞっていない部分もあるので注意。
Entory Point
ここで一括管理するようの領域を作っておきます。
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { createStore } from 'redux';
import App from './components/App';
import { rootReducer } from './modules';
import { Provider } from 'react-redux';
const store = createStore(rootReducer);
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root'));
これだけだとビルドエラーが出てうるさいので、必要最低限な実装を置いていきます。
import * as React from 'react';
const component: React.SFC = () => {
return (
<div/>
);
};
export default component;
import { combineReducers } from 'redux';
export const rootReducer = combineReducers({});
Action CreatorとReducer
ActionとReducerはまとめて作ってしまう方が作りやすいです。
ActionはStateに対する操作オブジェクト、ReducerはActionをStateに適用して新しいStateを作るものです。
自分の書き方を定めてしまえば、特に難しいことはありません。頭を使うのはpayloadに何をもたせよっかな〜くらいです。
それもReducerにこのActionぶちこんで新しいStateを作る時に必要な情報ってなんだっけ?くらいに思えば簡単に決められます。
元の例では最後のTodoのインデックスを状態として保持しているようですが、とりあえず配列の長さからとれるため、ここでは保持しません。
筆者はReducerの数だけディレクトリを掘るのが好きなので、todosとvisibilityFilterの2つのディレクトリとそれらをまとめるindex.tsxを作ります。
todos
ほとんど決まった形なのですが、どんな気持ちで書き下しているかコードコメントを付けておきます。
import { Action } from 'redux';
export type AddTodoPayload = { // todoを追加する時に必要なのはtodoの内容くらい
text: string;
};
export interface AddTodoAction extends Action {
type: 'ADD_TODO';
payload: AddTodoPayload;
}
export const addTodo = (payload: AddTodoPayload): AddTodoAction => {
return {
payload,
type: 'ADD_TODO',
};
};
import { Action } from 'redux';
export type ToggleTodoPayload = { // todoをトグルする時に必要なのはどのtodoかの情報くらい
id: number;
};
export interface ToggleTodoAction extends Action {
type: 'TOGGLE_TODO';
payload: ToggleTodoPayload;
}
export const toggleTodo = (payload: ToggleTodoPayload): ToggleTodoAction => {
return {
payload,
type: 'TOGGLE_TODO',
};
};
import { addTodo, AddTodoAction } from './AddTodo';
import { toggleTodo, ToggleTodoAction } from './ToggleTodo';
type Actions
= AddTodoAction
| ToggleTodoAction;
export type State = { // ページ全体で保持しとくべき情報はTodoの配列くらい
todos: {
id: number; // 連番を振っておく
text: string;
completed: boolean;
}[];
};
const init = (): State => {
return {
todos: [],
};
};
export const reducer = (state: State = init(), action: Actions) => {
switch (action.type) {
case 'ADD_TODO':
return {
todos: [
...state.todos,
{ // 既存の配列に新しいのを追加
id: state.todos.length,
text: action.payload.text,
completed: false,
},
],
};
case 'TOGGLE_TODO':
return {
todos: state.todos.map((e) => {
return e.id !== action.payload.id
? e
: { // 対象のidだけcompletedを反転
...e,
completed: !e.completed,
};
}),
};
default:
return state;
}
};
export const actionCreator = {
addTodo,
toggleTodo,
};
visibilityFilter
こっちは完了済みだけ表示とかするフィルター。フィルタは文字列じゃなくてストリングリテラル型にしてます。
import { Action } from 'redux';
type ShowAll = {
type: 'SHOW_ALL',
};
type ShowCompleted = {
type: 'SHOW_COMPLETED',
};
type ShowActive = {
type: 'SHOW_ACTIVE',
};
export type FilterType
= ShowAll
| ShowCompleted
| ShowActive;
export const showAll = (): FilterType => {
return {
type: 'SHOW_ALL',
};
};
export const showCompleted = (): FilterType => {
return {
type: 'SHOW_COMPLETED',
};
};
export const showActive = (): FilterType => {
return {
type: 'SHOW_ACTIVE',
};
};
export type SetVisibilityFilterPayload = { // とりあえずフィルターセットしといて、プレゼンテーション層で見え方の調整する
filter: FilterType;
};
export interface SetVisibilityFilterAction extends Action {
type: 'SET_VISIBILITY_FILTER';
payload: SetVisibilityFilterPayload;
}
export const setVisibilityFilter
= (payload: SetVisibilityFilterPayload): SetVisibilityFilterAction => {
return {
payload,
type: 'SET_VISIBILITY_FILTER',
};
};
import {
FilterType, showAll,
setVisibilityFilter, SetVisibilityFilterAction,
} from './SetVisibilityFilter';
export { FilterType, showAll, showCompleted, showActive } from './SetVisibilityFilter';
type Actions
= SetVisibilityFilterAction;
export type State = {
visibility: FilterType,
};
const init = (): State => {
return {
visibility: showAll(),
};
};
export const reducer = (state: State = init(), action: Actions) => {
switch (action.type) {
case 'SET_VISIBILITY_FILTER':
return {
visibility: action.payload.filter,
};
default:
return state;
}
};
export const actionCreator = {
setVisibilityFilter,
};
RootReducerを追加
先程、とりあえず最低限だけ書いたsrc/modules/index.tsxがこれです。大幅に加筆します。
RootStateとrootReducerのキー名は一致していなくてはダメです!
import { combineReducers } from 'redux';
import * as Todos from './todos';
import * as VisibilityFilter from './visibilityFilter';
export type RootState = {
todos: Todos.State;
visibilityFilter: VisibilityFilter.State;
};
export const rootReducer = combineReducers({
todos: Todos.reducer,
visibilityFilter: VisibilityFilter.reducer,
});
export const actionCreator = {
todos: Todos.actionCreator,
visibilityFilter: VisibilityFilter.actionCreator,
};
Presentation Component
普通のコンポーネントです。状態とか細かいことを気にせず、すべてPropsとして受け取って描画するように書けばいいです。なので、基本的にはStateless function componentとして書けるはずです。
元のドキュメントだとFooter内で未定義のコンポーネントを利用していて気持ち悪いので、そこだけ実装を後回しにします。
また、AddTodoをPresentation componentとContainer componentに分けますので、こちらにもAddTodoを実装します。
import * as React from 'react';
type Props = {
text: string;
completed: boolean;
onClick: () => void;
};
const component: React.SFC<Props> = (props: Props) => {
return (
<li
onClick={props.onClick}
style={{
textDecoration: props.completed ? 'line-through' : 'none',
}}
>
{props.text}
</li>
);
};
export default component;
import * as React from 'react';
import Todo from './Todo';
type Props = {
todos: {
id: number;
text: string;
completed: boolean;
}[];
toggleTodo: (id: number) => void;
};
const component: React.SFC<Props> = (props: Props) => {
return (
<ul>
{props.todos.map(todo => {
return (
<Todo
key={todo.id}
text={todo.text}
completed={todo.completed}
onClick={() => { props.toggleTodo(todo.id); }}
/>
);
})}
</ul>
);
};
export default component;
import * as React from 'react';
type Props = {
active: boolean;
children: React.ReactNode;
onClick: () => void;
};
const component: React.SFC<Props> = (props: Props) => {
return (
<button
onClick={props.onClick}
disabled={props.active}
style={{
marginLeft: '4px',
}}
>
{props.children}
</button>
);
};
export default component;
import * as React from 'react';
type Props = {
onSubmit: (text: string) => void;
};
type State = {
value: string; // Inputの中身を保持するためにStateを持つことにする。もちろんReduxに逃がしても良い。
};
class Component extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
value: '',
};
}
handleChange(event: React.ChangeEvent<HTMLInputElement>) {
event.preventDefault();
this.setState({
value: event.target.value,
});
}
handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const text = this.state.value.trim();
if (text === '') {
return;
}
this.props.onSubmit(text);
this.setState({ value: '' });
}
render() {
return (
<div>
<form onSubmit={(e) => { this.handleSubmit(e); } }>
<input
onChange={(e) => { this.handleChange(e); }}
value={this.state.value}
/>
<button type={'submit'}>
Add Todo
</button>
</form>
</div>
);
}
}
export default Component;
Container Component
それではPresentation ComponentとReduxをつなぎます。
ここも書き方は決まっていて、
- ReduxのStateからコンポーネントのPropsを抽出する
mapStateToProps
を書く - ReduxのDispatch ActionをコンポーネントのPropsに封じ込める
mapDispatchToProps
を書く -
connect
する
だけです。
そのため、このmapStateToProps
とmapDispatchToProps
が返却するオブジェクトをマージした時に、対象のコンポーネントのPropsを充足している必要があります。
なぜかconnect関数がコンパイルエラーになる場合は、これが守られていないケースが多い気がします。困った場合は、
- パラメータの数
- パラメータ名
- パラメータの型
- 渡すコンポーネント
をチェックすると、だいたいどれか間違ってます。
注意すべきは、mapDispatchToPropsからはStateを触れないということくらいです。
mergePropsという親玉みたいなやつもいますが使いませんし、使わないと死ぬ状況にも陥ったことは今のところありません。
import { connect } from 'react-redux';
import { Action, Dispatch } from 'redux';
import { actionCreator, RootState } from '../modules';
import TodoList from '../components/TodoList';
const mapStateToProps = (state: RootState) => {
const filter = () => {
switch (state.visibilityFilter.visibility.type) {
case 'SHOW_ALL':
return state.todos.todos;
case 'SHOW_COMPLETED':
return state.todos.todos.filter(e => e.completed);
case 'SHOW_ACTIVE':
return state.todos.todos.filter(e => !e.completed);
default:
throw new Error('Unknown filter.');
}
};
return {
todos: filter()
}
};
const mapDispatchToProps = (dispatch: Dispatch<Action>) => {
return {
toggleTodo: (id: number) => {
dispatch(actionCreator.todos.toggleTodo({id: id}));
}
}
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(TodoList);
import { connect } from 'react-redux';
import { Action, Dispatch } from 'redux';
import { actionCreator, RootState } from '../modules';
import Link from '../components/Link';
import { FilterType } from '../modules/visibilityFilter';
type OwnProps = {
filter: FilterType;
}
const mapStateToProps = (state: RootState, ownProps: OwnProps) => {
return {
active: ownProps.filter === state.visibilityFilter.visibility,
};
};
const mapDispatchToProps = (dispatch: Dispatch<Action>, ownProps: OwnProps) => {
return {
onClick: () => { dispatch(actionCreator.visibilityFilter.setVisibilityFilter({ filter: ownProps.filter, })) },
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(Link);
import { connect } from 'react-redux';
import { Action, Dispatch } from 'redux';
import { actionCreator } from '../modules';
import AddTodo from '../components/AddTodo';
const mapStateToProps = () => {
return {};
};
const mapDispatchToProps = (dispatch: Dispatch<Action>) => {
return {
onSubmit: (text: string) => {
dispatch(actionCreator.todos.addTodo({text}));
}
}
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(AddTodo);
仕上げ
さきほど保留した、Footerと最初に取り敢えず配置したAppのリバイスをすれば完成です。
import * as React from 'react';
import FilterLink from '../containers/FilterLink';
import { showAll, showCompleted, showActive } from '../modules/visibilityFilter';
const component: React.SFC = () => {
return (
<div>
<span>Show: </span>
<FilterLink filter={showAll()}>
All
</FilterLink>
<FilterLink filter={showActive()}>
Active
</FilterLink>
<FilterLink filter={showCompleted()}>
Completed
</FilterLink>
</div>
);
};
export default component;
import * as React from 'react';
import AddTodo from '../containers/AddTodo';
import VisibleTodoList from '../containers/VisibleTodoList';
import Footer from './Footer';
const component: React.SFC = () => {
return (
<div>
<AddTodo />
<VisibleTodoList />
<Footer />
</div>
);
};
export default component;
まとめ
実装してみるとわかると思いますがReduxを使うと、module下にあるStateやAction CreatorとComponent下にあるViewに相当する部分が疎結合になります。
今回はReduxのTutorialに沿っての実装だったため、module側から実装しましたが、Component側から実装してもいいですし、複数人で開発するならば同時進行で開発しても良いと思います。
できあがったソース群はこちら
https://github.com/IgnorantCoder/webpack-typescript-react-redux-sample
おまけ (ありがちなエラーの解決法)
TypeScript
error TS2307: Cannot find module 'redux'.
tsconfig.jsonに"moduleResolution": "node"
を追加
TSLint
file should end with a newline
ファイルの最後に空行が必要
Missing trailing comma
jsonを作る時に、最後の要素のあとにもカンマが必要
{
hoge: "fuga",
foo: "poo"
}
{
hoge: "fuga",
foo: "poo",
}
Expected property shorthand in object literal
オブジェクトリテラルの省略記法を使う
const hoge: string = 'fuga';
const foo: string = 'poo';
{
hoge: hoge,
foo: foo,
}
const hoge: string = 'fuga';
const foo: string = 'poo';
{
hoge,
foo,
}
Runtime Error
Uncaught TypeError: Cannot read property 'setState' of undefined
classのメンバに暗黙にthisがバインドされないためです。呼び出す時にアロー表記にすれば解決。
Uncaught TypeError: xxx.map is not a function
なんでや!配列をmapさせろや!!!十中八九xxxにObjectが入ってしまっています。