50
53

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 5 years have passed since last update.

ReactをTypeScriptで書ける環境で、ReduxのTutorialをしてみる

Last updated at Posted at 2018-05-16

はじめに

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

ここで一括管理するようの領域を作っておきます。

src/index.tsx
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'));

これだけだとビルドエラーが出てうるさいので、必要最低限な実装を置いていきます。

src/components/App.tsx
import * as React from 'react';

const component: React.SFC = () => {
    return (
        <div/>
    );
};

export default component;
src/modules/index.ts
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

ほとんど決まった形なのですが、どんな気持ちで書き下しているかコードコメントを付けておきます。

src/modules/todos/AddTodo.ts
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',
    };
};
src/modules/todos/ToggleTodo.ts
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',
    };
};
src/modules/todos/index.ts
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

こっちは完了済みだけ表示とかするフィルター。フィルタは文字列じゃなくてストリングリテラル型にしてます。

src/modules/visibilityFilter/SetVisibilityFilter.ts
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',
        };
    };
src/modules/visibilityFilter/index.ts
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のキー名は一致していなくてはダメです!

src/modules/index.ts
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を実装します。

src/components/Todo.tsx
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;
src/components/TodoList.tsx
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;

src/components/Link.tsx
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;
src/components/AddTodo.tsx
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する

だけです。
そのため、このmapStateToPropsmapDispatchToPropsが返却するオブジェクトをマージした時に、対象のコンポーネントのPropsを充足している必要があります。
なぜかconnect関数がコンパイルエラーになる場合は、これが守られていないケースが多い気がします。困った場合は、

  • パラメータの数
  • パラメータ名
  • パラメータの型
  • 渡すコンポーネント

をチェックすると、だいたいどれか間違ってます。

注意すべきは、mapDispatchToPropsからはStateを触れないということくらいです。
mergePropsという親玉みたいなやつもいますが使いませんし、使わないと死ぬ状況にも陥ったことは今のところありません。

src/containers/VisibleTodoList.tsx
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);
src/containers/FilterLink.tsx
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);
src/containers/AddTodo.tsx
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のリバイスをすれば完成です。

src/component/Footer.tsx
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;
src/component/App.tsx
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を作る時に、最後の要素のあとにもカンマが必要

NG.ts
{
    hoge: "fuga",
    foo: "poo"
}
OK.ts
{
    hoge: "fuga",
    foo: "poo",
}

Expected property shorthand in object literal

オブジェクトリテラルの省略記法を使う

NG.ts
const hoge: string = 'fuga';
const foo: string = 'poo';
{
    hoge: hoge,
    foo: foo,
}
OK.ts
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が入ってしまっています。

50
53
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
50
53

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?