2020年2月現在、React ReduxのBasic Tutorialで紹介されているサンプルアプリケーション(Todoアプリ)はJavaScriptで書かれています。
今回、サンプルアプリケーションのTypeScript版を作成したので紹介します。
なお、サンプルアプリケーションの概要を知っている前提で話を進めますので、概要を知らない方は公式ドキュメントとあわせて読んでいただければと思います。
react-redux
は7.1.16、react
は17.0.1、typescript
は3.8.3を利用しています。
目次
Todoアプリの概要
今回作成するTodoアプリでできることは以下の通りです。
- 「Add Todo」ボタンでTodoを追加できる
- Todoをクリックするとステータスのトグル操作(completed/incomplete)ができる
- all/completed/incompleteのステータスでTodoをフィルタリングできる
ディレクトリ構成は以下のようになっています。
.
├── TodoApp.tsx
├── components
│ ├── AddTodo.tsx
│ ├── Todo.tsx
│ ├── TodoList.tsx
│ └── VisibilityFilters.tsx
├── constants.ts
├── index.tsx
├── react-app-env.d.ts
├── redux
│ ├── actionTypes.ts
│ ├── actions.ts
│ ├── reducers
│ │ ├── index.ts
│ │ ├── todos.ts
│ │ └── visibilityFilter.ts
│ ├── selectors.ts
│ ├── store.ts
│ └── types.ts
└── styles.css
アプリケーションのひな型をローカル環境に準備する
create-react-app
でreduxを利用する場合は、--template
オプションを利用します。
JavaScriptの場合はredux
、TypeScriptの場合はredux-typescript
を指定します。
今回はTypeScriptで記述するのでredux-typescript
を指定します。
### create-react-appを利用してアプリケーションを作成
$ npx create-react-app redux-todo-ts --template redux-typescript
$ cd redux-todo-ts
### アプリケーションで必要になるnpmのインストール
$ yarn add classnames
$ yarn add --dev @types/classnames
### srcディレクトリのファイルを削除
$ cd redux-todo-app
$ rm -rf src/*
なお、create-react-app
ではRedux ToolKitのReduxが利用されています。
Redux ToolkitとはReduxをより簡潔に記述するためのツールです。Reduxの公式ドキュメントでも紹介されてます。1
モックの作成
TodoアプリのモックはCodeSandboxで公開されています。
今回は上記のJavaScriptのソースコードをコピーし、徐々にTypeScript化するというアプローチをとります。
TypeScriptの拡張子は.ts
(コンポーネントを扱うファイルの拡張子は.tsx
)となるので注意してください。
なお、モックをTypeScript化した最終的なソースコードはnishina555/react-redux-todoapp-mock-tsで公開しています。
本記事では一部ソースコードを省略している箇所もあるため、上記のリポジトリもあわせて参考にしてください。
定数ファイルにconst assertionを導入する
オブジェクトのプロパティをRead Onlyにできるconst assertion
という機能がTypeScript 3.4から導入されているので利用してみましょう。
const assertion
はas const
を末尾につけるだけで設定が完了します。
src/constants
export const VISIBILITY_FILTERS = {
ALL: "all",
COMPLETED: "completed",
INCOMPLETE: "incomplete"
} as const;
// {
// readonly ALL: "all";
// readonly COMPLETED: "completed";
// readonly INCOMPLETE: "incomplete";
// }
const assertion
の利用した場合としない場合の比較は以下の通りです。
// const assertionを利用した場合
const x = { a: 1 } as const;
x.a = 3 // error
// const assertionを利用しない場合
const x = { a: 1 };
x.a = 3
console.log(x.a) // 3
Stateの型定義をする
アプリケーションのStateはTodoリストの内容を管理するtodos
と、Todoをフィルタリングする際に利用するフィルターの状態を管理するvisibilityfilter
で構成されています。
具体的には以下のような値がStateに保存されています。
{
todos: {
allIds: [
1,
2
],
byIds: {
'1': {
content: 'do something',
completed: false
},
'2': {
content: 'go somewhere',
completed: false
}
}
},
visibilityFilter: 'all'
}
上記のStateの型定義をします。
まず、todos
の型定義です。
byIds
はkey
にTodoのid
、value
に当該Todoの情報がセットされる連想配列です。
連想配列の型は{[key: インデックスの型]: 値の型}
で定義します。
todos
の型は以下の通りです。
export type TodoState = {
allIds: Array<number>;
byIds: { [key: string]: TodoItemState };
};
export type TodoItemState = {
content: string;
completed: boolean;
};
次に、visibilityfilter
の型定義です。
visibilityfilterには"all"
, "completed"
, "incomplete"
いずれかの値がセットされるunion型です。
定数オブジェクトを利用してunion型の定義をする場合、以下のようになります。
type VisibilityFilterTypes = typeof VISIBILITY_FILTERS[keyof typeof VISIBILITY_FILTERS];
// "all" | "completed" | "incomplete"
最終的なソースコードは以下の通りです。
今回は src/redux/types.ts
というファイルを新しく作り、そこにStateの型定義をしました。
src/redux/types.ts
import { VISIBILITY_FILTERS } from "../constants";
// visibilityFilter
export type VisibilityFilterTypes = typeof VISIBILITY_FILTERS[keyof typeof VISIBILITY_FILTERS];
// todos
export type TodoState = {
allIds: Array<number>;
byIds: { [key: string]: TodoItemState };
};
export type TodoItemState = {
content: string;
completed: boolean;
};
// state
export type State = {
visibilityFilter: VisibilityFilterTypes;
todos: TodoState;
};
補足: 定数オブジェクトを利用した型定義の方法について
JavaScriptで書かれていたサンプルアプリケーションでは以下のように定数の定義がされていました。
export const VISIBILITY_FILTERS = {
ALL: "all",
COMPLETED: "completed",
INCOMPLETE: "incomplete"
} as const;
今回は以下のようにしてunion型の定義をしました。
const VISIBILITY_FILTERS = {
ALL: "all",
COMPLETED: "completed",
INCOMPLETE: "incomplete",
} as const;
type VisibilityFilterTypes = typeof VISIBILITY_FILTERS[keyof typeof VISIBILITY_FILTERS]; // "all" | "completed" | "incomplete"
// const filter: VisibilityFilterTypes = VISIBILITY_FILTERS.ALL
// console.log(filter)
// => "all"
type VisibilityFilterTypes = typeof VISIBILITY_FILTERS[keyof typeof VISIBILITY_FILTERS];
の部分を分解すると以下のようになります。
type TypeOfVisibilityFilters = typeof VISIBILITY_FILTERS
// {
// readonly ALL: "all";
// readonly COMPLETED: "completed";
// readonly INCOMPLETE: "incomplete";
// }
type Key = keyof TypeOfVisibilityFilters
// "ALL" | "COMPLETED" | "INCOMPLETE"
type VisibilityFilterTypes = TypeOfVisibilityFilters[Key]
// "all" | "completed" | "incomplete"
// よって以下のようになる
type VisibilityFilterTypes = typeof VISIBILITY_FILTERS[keyof typeof VISIBILITY_FILTERS];
// "all" | "completed" | "incomplete"
なお、TypeScriptではenum型で書くこともできます。
enum VISIBILITY_FILTERS {
ALL = "all",
COMPLETED = "completed",
INCOMPLETE = "incomplete",
}
// const filter: VISIBILITY_FILTERS = VISIBILITY_FILTERS.ALL
// console.log(filter)
// => "all"
emun型ではなくunion型を使ったほうがよいという記事をいくつか見かけたため、今回はunion型で定義しました。
Selectorsの型定義
Stateの型定義ができたので、Stateの情報を抽出するSelectorsも型定義します。
src/redux/selectors.ts
import { State } from "./types";
export type TodoItem = {
content: string;
completed: boolean;
id: number;
};
export const getTodoList = (store: State): Array<number> =>
store && store.todos ? store.todos.allIds : [];
export const getTodoById = (store: State, id: number): TodoItem => {
return { ...store.todos.byIds[id], id };
};
export const getTodos = (store: State): Array<TodoItem> =>
getTodoList(store).map((id) => getTodoById(store, id));
Actionの型定義
定数として利用されているActionTypesをenumにします。
src/redux/actionTypes.ts
export enum ActionTypes {
ADD_TODO = "ADD_TODO",
TOGGLE_TODO = "TOGGLE_TODO",
SET_FILTER = "SET_FILTER",
}
また、Actionごとに型を定義します。
たとえば、Todoを追加するaddTodo
は以下のようになります。
src/redux/actions.ts
import { ActionTypes } from "./actionTypes";
let nextTodoId = 0;
type AddTodoAction = {
type: ActionTypes.ADD_TODO;
payload: {
id: number;
content: string;
};
};
export const addTodo = (content: string): AddTodoAction => ({
type: ActionTypes.ADD_TODO,
payload: {
id: ++nextTodoId,
content,
},
});
各Actionの型をまとめたunion型を作成します。
export type TodoActions = AddTodoAction | ToggleTodoAction | SetFilterAction;
actions.ts
の最終的なコードは以下の通りです。
src/redux/actions.ts
import { ActionTypes } from "./actionTypes";
import { VisibilityFilterTypes } from "./types";
let nextTodoId = 0;
type AddTodoAction = {
type: ActionTypes.ADD_TODO;
payload: {
id: number;
content: string;
};
};
export const addTodo = (content: string): AddTodoAction => ({
type: ActionTypes.ADD_TODO,
payload: {
id: ++nextTodoId,
content,
},
});
type ToggleTodoAction = {
type: ActionTypes.TOGGLE_TODO;
payload: {
id: number;
};
};
export const toggleTodo = (id: number): ToggleTodoAction => ({
type: ActionTypes.TOGGLE_TODO,
payload: { id },
});
type SetFilterAction = {
type: ActionTypes.SET_FILTER;
payload: {
filter: VisibilityFilterTypes;
};
};
export const setFilter = (filter: VisibilityFilterTypes): SetFilterAction => ({
type: ActionTypes.SET_FILTER,
payload: { filter },
});
export type TodoActions = AddTodoAction | ToggleTodoAction | SetFilterAction;
Reducersの型定義
ここまで作成したStateやActionの型をReducerに適用します。
src/redux/reducers/visibilityFilters.ts
import { ActionTypes } from "../actionTypes";
import { VISIBILITY_FILTERS } from "../../constants";
import { VisibilityFilterTypes } from "../types";
import { TodoActions } from "../actions";
const initialState: VisibilityFilterTypes = VISIBILITY_FILTERS.ALL;
const visibilityFilter = (state = initialState, action: TodoActions) => {
switch (action.type) {
case ActionTypes.SET_FILTER: {
return action.payload.filter;
}
default: {
return state;
}
}
};
export default visibilityFilter;
src/redux/reducers/todos.ts
import { ActionTypes } from "../actionTypes";
import { TodoActions } from "../actions";
import { TodoState } from "../types";
const initialState: TodoState = {
allIds: [],
byIds: {},
};
const todos = (state = initialState, action: TodoActions) => {
switch (action.type) {
case ActionTypes.ADD_TODO: {
const { id, content } = action.payload;
return {
...state,
allIds: [...state.allIds, id],
byIds: {
...state.byIds,
[id]: {
content,
completed: false,
},
},
};
}
case ActionTypes.TOGGLE_TODO: {
const { id } = action.payload;
return {
...state,
byIds: {
...state.byIds,
[id]: {
...state.byIds[id],
completed: !state.byIds[id].completed,
},
},
};
}
default:
return state;
}
}
export default todos
コンポーネントのTypeScript化
コンポーネントで利用するProps, Stateのtypeを定義し、React.Component<Props, State>
を継承します。
たとえば、AddTodo.tsx
は以下のようになります。
src/components/AddTodo.tsx
import React from "react";
type AddTodoState = {
input: string;
};
class AddTodo extends React.Component<{}, AddTodoState> {
constructor(props: {}) {
super(props);
this.state = { input: "" };
}
updateInput = (input: string) => {
this.setState({ input });
};
handleAddTodo = () => {
// dispatches actions to add todo
// sets state back to empty string
};
render() {
return (
<div>
<input
onChange={(e) => this.updateInput(e.target.value)}
value={this.state.input}
/>
<button className="add-todo" onClick={this.handleAddTodo}>
Add Todo
</button>
</div>
);
}
}
export default AddTodo;
他のコンポーネントについては説明を省略します。
起動の確認
モックのTypeScript化が完了したので動作の確認をします。
yarn start
でTodoアプリが表示されればOKです。
Redux DevToolsの導入
開発をする過程でStateの状態を確認する必要があるためRedux DevToolsを導入します。
Redux DevTools Extensionの手順に従ってインストールします。
$ yarn add --dev redux-devtools-extension
src/store.ts
import { createStore } from "redux";
import rootReducer from "./reducers";
export default createStore(
rootReducer,
(window as any).__REDUX_DEVTOOLS_EXTENSION__ &&
(window as any).__REDUX_DEVTOOLS_EXTENSION__()
);
ReactとReduxをつなぎこむ
公式ドキュメントで書かれている内容と同じ修正です。
<Provider store={store}>
でコンポーネントを囲うことでReactとReduxの連携をします。
src/index.tsx
import React from "react";
import ReactDOM from "react-dom";
import TodoApp from "./TodoApp";
import { Provider } from 'react-redux'
import store from './redux/store'
const rootElement = document.getElementById("root");
ReactDOM.render(
<Provider store={store}>
<TodoApp />
</Provider>,
rootElement
);
前回と同じくTodoアプリが表示されればOKです。
コンポーネントとReduxをつなぎこむ
ReactとReduxのつなぎこみが完了したので、各コンポーネントがReduxと連携できるようにします。
コンポーネント内で、ReduxのStoreで管理されているStateを扱う場合や、ReduxのActionを実行(dispatch)したい場合はconnect
関数を利用します。
StateとActionの利用有無によってconnect
関数の記述方法は異なります。
まとめると以下の通りです。
Stateを利用しない | Stateを利用する | |
---|---|---|
Actionを実行しない | connect()(Component) | connect(mapStateToProps)(Component) |
Actionを実行する | connect(null,mapDispatchToProps)(Component) | connect(mapStateToProps, mapDispatchToProps)(Component) |
特にmapDispatchToProps
の部分は{アクションクリエイター名}
のように記述をすることで、当該コンポーネント内のpropsオブジェクトとしてActionCreatorを利用できます。
コンポーネントからActionを実行(dispatch)できるようにする
Todoを追加するコンポーネント(AddTodo)で、実際に「Todoを追加する」というActionが実行できるようにします。
addTodo
は「Todoを追加する」というActionを返すActionCreatorです。
src/components/AddTodo.tsx
import { connect } from 'react-redux'
import { addTodo } from '../redux/actions'
(略)
export default connect(
null,
{ addTodo }
)(AddTodo)
{ addTodo }
の部分をmapDispatchToProps
に書き換えると以下のようになります。
dispatch
関数の型(Dispatch
)はredux
からimportします。
import { connect } from 'react-redux'
import { addTodo } from '../redux/actions'
import { Dispatch } from "redux"
const mapDispatchToProps = (dispatch: Dispatch) => {
return {
addTodo: (input: string) => dispatch(addTodo(input))
}
}
(略)
export default connect(
null,
mapDispatchToProps
)(AddTodo)
addTodo
がAddTodoコンポーネントで利用できるようになったので、AddTodoProps
の型に追加し、React.Component
の引数およびconstructor
のPropsの型として利用します。
ActionCreator(addTodo
)はActionを返すので、戻り値はTodoActions
になります。
import { TodoActions } from '../redux/actions'
type AddTodoProps = {
addTodo: (input: string) => TodoActions
}
type AddTodoState = {
input: string;
};
class AddTodo extends React.Component<AddTodoProps, AddTodoState> {
constructor(props: AddTodoProps) {
super(props);
this.state = { input: "" };
}
(略)
これで、AddTodoコンポーネントにおいてthis.props.addTodo
という形でaddTodo
が利用できます。
handleAddTodo = () => {
this.props.addTodo(this.state.input)
this.setState({ input: '' })
};
画面のボタンから追加したTodoの内容がRedux DevToolsで確認できればOKです。
もしNo store found. Make sure to follow the instructions.
と表示され、Redux DevToolsが見られない場合はRedux DevToolsが正常にインストールできていないので、再度確認してみてください。
AddTodoコンポーネントの最終的なコードは以下の通りです。
src/components/AddTodo.tsx
import React from "react";
import { connect } from 'react-redux'
import { addTodo } from '../redux/actions'
import { TodoActions } from '../redux/actions'
type AddTodoProps = {
addTodo: (input: string) => TodoActions
}
type AddTodoState = {
input: string;
};
class AddTodo extends React.Component<AddTodoProps, AddTodoState> {
constructor(props: AddTodoProps) {
super(props);
this.state = { input: "" };
}
updateInput = (input: string) => {
this.setState({ input });
};
handleAddTodo = () => {
this.props.addTodo(this.state.input)
this.setState({ input: '' })
};
render() {
return (
<div>
<input
onChange={(e) => this.updateInput(e.target.value)}
value={this.state.input}
/>
<button className="add-todo" onClick={this.handleAddTodo}>
Add Todo
</button>
</div>
);
}
}
export default connect(
null,
{ addTodo }
)(AddTodo)
コンポーネントでStateを利用できるようにする
現在のStateからtodoオブジェクトを返すgetTodos
メソッドを利用して、コンポーネントで表示するTodoを取得できるようにします。
src/components/TodoList.tsx
import { connect } from 'react-redux';
import { getTodos } from "../redux/selectors";
import { State } from "../redux/types"
export default connect((state: State) => ({ todos: getTodos(state) }))(TodoList);
(state: State) => ({ todos: getTodos(state) })
の部分をmapStateToProps
に書き換えると以下のようになります。
mapStateToProps
で返された値はTodoListコンポーネントのpropsとして利用されるため返却値の型はTodoListProps
です。
const mapStateToProps = (state: State): TodoListProps => ({
todos: getTodos(state)
})
export default connect(mapStateToProps)(TodoList);
Propsの型定義とReact.FC
の引数の修正をします。
import { TodoItem } from "../redux/selectors";
type TodoListProps = {
todos: Array<TodoItem>;
}
const TodoList: React.FC<TodoListProps> = ({ todos }) => {
追加したTodoが画面に表示されればOKです。
最終的なソースコードは以下の通りです。
src/components/TodoList.tsx
import React from "react";
import Todo from "./Todo";
import { connect } from "react-redux";
import { getTodos, TodoItem } from "../redux/selectors";
import { State } from "../redux/types"
type TodoListProps = {
todos: Array<TodoItem>;
}
const TodoList: React.FC<TodoListProps> = ({ todos }) => (
<ul className="todo-list">
{todos && todos.length
? todos.map((todo, index) => {
return <Todo key={`todo-${todo.id}`} todo={todo} />;
})
: "No todos, yay!"}
</ul>
);
export default connect( (state: State) => ({ todos: getTodos(state) }))(TodoList);
Todoのcompleted/incompleteが切替られるようにする
Todoのステータスをトグル操作するActionCreator(toggleTodo
)をコンポーネントで利用できるようにします。
src/components/Todo.tsx
import { connect } from "react-redux";
import { toggleTodo } from "../redux/actions";
export default connect(
null,
{ toggleTodo }
)(Todo);
Propsの型定義を修正します。
import { TodoActions } from '../redux/actions';
import { TodoItem } from "../redux/selectors";
type TodoProps = {
todo: TodoItem;
toggleTodo: (id: number) => TodoActions
};
const Todo: React.FC<TodoProps> = ({ todo, toggleTodo }) => (
onClick
でActionCreatorを実行します。
onClick={() => toggleTodo(todo.id) }
Todoをクリックし、ステータスが変更できればOKです。
最終的なソースコードは以下の通りです。
src/components/Todo.tsx
import React from "react";
import cx from "classnames";
import { connect } from "react-redux";
import { toggleTodo } from "../redux/actions";
import { TodoActions } from '../redux/actions';
import { TodoItem } from "../redux/selectors";
type TodoProps = {
todo: TodoItem;
toggleTodo: (id: number) => TodoActions
};
const Todo: React.FC<TodoProps> = ({ todo, toggleTodo }) => (
<li
className="todo-item"
onClick={() => toggleTodo(todo.id) }
>
{todo && todo.completed ? "👌" : "👋"}{" "}
<span
className={cx(
"todo-item__text",
todo && todo.completed && "todo-item__text--completed"
)}
>
{todo.content}
</span>
</li>
);
export default connect(
null,
{ toggleTodo }
)(Todo);
ステータスに応じてTodoをフィルタリングできるようにする
フィルタリング対象のステータスを切り替えるsetFilter
と、現在のフィルター情報を保持するvisibilityFilter
をコンポーネントで利用できるようにします。
前項と同じく、connect関数を利用します。
最終的なソースコードは以下の通りです。
src/components/VisibilityFilters.tsx
import React from "react";
import cx from "classnames";
import { VISIBILITY_FILTERS } from "../constants";
import { connect } from "react-redux";
import { setFilter } from "../redux/actions";
import { State, VisibilityFilterTypes } from "../redux/types"
import { TodoActions } from '../redux/actions'
type VisibilityFiltersProps = {
activeFilter: VisibilityFilterTypes;
setFilter: (filter: VisibilityFilterTypes) => TodoActions
}
const VisibilityFilters: React.FC<VisibilityFiltersProps> = ({ activeFilter, setFilter }) => {
return (
<div className="visibility-filters">
{(Object.keys(VISIBILITY_FILTERS) as Array<
keyof typeof VISIBILITY_FILTERS
>).map((filterKey) => {
const currentFilter = VISIBILITY_FILTERS[filterKey];
return (
<span
key={`visibility-filter-${currentFilter}`}
className={cx(
"filter",
currentFilter === activeFilter && "filter--active"
)}
onClick={() => setFilter(currentFilter)}
>
{currentFilter}
</span>
);
})}
</div>
);
};
const mapStateToProps = (state: State) => {
return { activeFilter: state.visibilityFilter };
};
export default connect(
mapStateToProps,
{ setFilter }
)(VisibilityFilters);
フィルターのリンク(all/completed/incomplete)をクリックし、Redux DevTools上でstateの変化が確認できればOKです。
フィルタリング結果を画面に反映させる
Selectorsに新しいメソッド(getTodosByVisibilityFilter
)を追加します。
src/redux/selectors.ts
import { State, VisibilityFilterTypes } from './types';
import { VISIBILITY_FILTERS } from "../constants";
export const getTodosByVisibilityFilter = (store: State, visibilityFilter: VisibilityFilterTypes): TodoItem[] => {
const allTodos = getTodos(store)
switch (visibilityFilter) {
case VISIBILITY_FILTERS.COMPLETED:
return allTodos.filter(todo => todo.completed)
case VISIBILITY_FILTERS.INCOMPLETE:
return allTodos.filter(todo => !todo.completed)
case VISIBILITY_FILTERS.ALL:
default:
return allTodos
}
}
現在のフィルター情報を保持するvisibilityFilter
と、先ほど作成したgetTodosByVisibilityFilter
を利用してTodoのフィルタリングを行います。
最終的なコードは以下の通りです。
src/components/TodoList.tsx
import React from "react";
import Todo from "./Todo";
import { connect } from 'react-redux';
import { State } from "../redux/types"
import { getTodosByVisibilityFilter, TodoItem } from "../redux/selectors";
type TodoListProps = {
todos: Array<TodoItem>;
}
const TodoList: React.FC<TodoListProps> = ({ todos }) => {
return (
<ul className="todo-list">
{todos && todos.length
? todos.map((todo: any, index: any) => {
return <Todo key={`todo-${todo.id}`} todo={todo} />;
})
: "No todos, yay!"}
</ul>
);
};
const mapStateToProps = (state: State): TodoListProps => {
const { visibilityFilter } = state
const todos = getTodosByVisibilityFilter(state, visibilityFilter)
return { todos }
}
export default connect(mapStateToProps)(TodoList);
フィルターのリンク(all/completed/incomplete)をクリックし、画面のTodoがフィルタリングされていればOKです。
最終的なアプリケーション
さいごに
React ReduxのチュートリアルをTypeScript化しました。
全体を通してモックをTypeScript化する部分が一番たいへんかと思います。モックをTypeScript化した最終的なソースコードはnishina555/react-redux-todoapp-mock-tsで公開していますので活用していただければと思います。
Twitter(@nishina555)やってます。フォローしてもらえるとうれしいです!