Redux Toolkitのレビュー
前回の投稿で、従来のReact, Reduxで書いていた部分をRedux Toolkit (RTK) でリファクタしたが、今回はRTKのメソッドについてまとめていく。
Redux Toolkit (RTK) のメソッド一覧
configureStore()
configureStore()
に渡すオプションの型定義を見てみる。
/** * Options for `configureStore()`. * * @public */ export declare interface ConfigureStoreOptions<S = any, A extends Action = AnyAction, M extends Middlewares<S> = Middlewares<S>> { /** * A single reducer function that will be used as the root reducer, or an * object of slice reducers that will be passed to `combineReducers()`. */ reducer: Reducer<S, A> | ReducersMapObject<S, A>; /** * An array of Redux middleware to install. If not supplied, defaults to * the set of middleware returned by `getDefaultMiddleware()`. */ middleware?: M; /** * Whether to enable Redux DevTools integration. Defaults to `true`. * * Additional configuration can be done by passing Redux DevTools options */ devTools?: boolean | EnhancerOptions; /** * The initial state, same as Redux's createStore. * You may optionally specify it to hydrate the state * from the server in universal apps, or to restore a previously serialized * user session. If you use `combineReducers()` to produce the root reducer * function (either directly or indirectly by passing an object as `reducer`), * this must be an object with the same shape as the reducer map keys. */ preloadedState?: DeepPartial<S extends any ? S : S>; /** * The store enhancers to apply. See Redux's `createStore()`. * All enhancers will be included before the DevTools Extension enhancer. * If you need to customize the order of enhancers, supply a callback * function that will receive the original array (ie, `[applyMiddleware]`), * and should return a new array (such as `[applyMiddleware, offline]`). * If you only need to add middleware, you can use the `middleware` parameter instaead. */ enhancers?: StoreEnhancer[] | ConfigureEnhancersCallback; }
reducer
が必須のオプションで、それ以外のmiddleware
, devTools
, preloadedState
, enhancers
は任意となっている。
reducer
部分には、複数のreducerを従来のcombineReducer
の要領でオブジェクト形式で組み合わせて入れることもできれば、単体のreducerを入れることもできる。
combineReducer
でラップして渡すこともできるけど不要。
export default configureStore({ reducer: { todos: todosSlice.reducer, selectedTodo: selectedTodoSlice.reducer, counter: counterSlice.reducer, } }) export default configureStore({ reducer: todosSlice.reducer })
middleware
は、何も渡さなければgetDefaultMiddleware()
が適用される。
もし何らかの理由でそれらのgetDefaultMiddleware()
の適用をしたくなければ、空配列を渡せばよい。
もしくはgetDefaultMiddleware()
に足すかたちでミドルウェアを加えてあげてもよい。
またcombineReducer
でのラップが不要であるのと同じように、applyMiddleware
でラップする必要もない。
export default configureStore({ reducer: { // ... }, middleware: [], }) export default configureStore({ reducer: { // ... }, middleware: [...getDefaultMiddleware(), logger], })
RTKではdevToolsも合わせてついてくるので、出し分け制御もできる。
export default configureStore({ reducer: { // ... }, middleware: : { // ... }, devTools: process.env.NODE_ENV !== 'production' })
大体ここまででほとんどカバーできるだろう。
createSlice()
createSlice()
に渡すオプションの型定義を見てみる。
/** * Options for `createSlice()`. * * @public */ export declare interface CreateSliceOptions<State = any, CR extends SliceCaseReducers<State> = SliceCaseReducers<State>> { /** * The slice's name. Used to namespace the generated action types. */ name: string; /** * The initial state to be returned by the slice reducer. */ initialState: State; /** * A mapping from action types to action-type-specific *case reducer* * functions. For every action type, a matching action creator will be * generated using `createAction()`. */ reducers: ValidateSliceCaseReducers<State, CR>; /** * A mapping from action types to action-type-specific *case reducer* * functions. These reducers should have existing action types used * as the keys, and action creators will _not_ be generated. * Alternatively, a callback that receives a *builder* object to define * case reducers via calls to `builder.addCase(actionCreatorOrType, reducer)`. */ extraReducers?: CaseReducers<NoInfer<State>, any> | ((builder: ActionReducerMapBuilder<NoInfer<State>>) => void); }
公式ドキュメントにはcreateSlice
について下記のように書いてある。
A function that accepts an initial state, an object full of reducer functions, and a "slice name", and automatically generates action creators and action types that correspond to the reducers and state.
action typeはslice名の後にスラッシュ区切りで、合わせて自動で生成してくれる。
initialStateも必須となっているので、従来のように初期のstateの型と値を与えることを忘れることもなくなる。
また、ここが一番の利点思っている点として、immerによりstateをmutableにハンドルすることができるようになる。
だた、immerを使用する際の注意点として、
- immerはあくまで、mutableにinputされた書き方をうまくハンドリングしてimmutableに処理してくれているだけ
- プリミティブ型に対してはmutateできない
- stateのmutateをするか もしくは 新しいstateを返すか のどちらかのみ
もう一つ、prepare
を使用することでactionに渡す前に処理を一回挟めるので、予め渡すオブジェクトを用意する場合などに使える。
Redux Toolkitを用いたリファクタ
React, Redux, TypeScriptの組み合わせだと、どうしてもファイル数が増えて、記述があちらこちらに散らばることが多々あり、リーダビリティが下がってしまう。
Redux Toolkit (RTK) を使えば、あちらこちらに記述していた書き方を集約して書ける以外にも、redux周りの面倒なパッケージのインストールやミドルウェアの部分も包含されているので、手間が省ける。
こちらの動画にわかりやすい比較サンプルが載っていたので、少々こちらでいじった上で、従来の書き方とRedux Toolkitを用いた書き方を比べてみる。
下記では、理解のしやすさ重視で、TODOリストのReduxを、従来の書き方とRTKでの書き方で1ファイルにまとめて見比べている。
コード
redux-original.ts
import { combineReducers, createStore, applyMiddleware } from 'redux'; import { v1 as uuid } from 'uuid'; import thunk from 'redux-thunk'; import logger from 'redux-logger'; import { composeWithDevTools } from 'redux-devtools-extension'; export interface Todo { id: string; desc: string; isComplete: boolean; } export interface State { todos: Todo[]; selectedTodo: string | null; counter: number; } /** * Constants */ const CREATE_TODO = 'CREATE_TODO'; const EDIT_TODO = 'EDIT_TODO'; const TOGGLE_TODO = 'TOGGLE_TODO'; const DELETE_TODO = 'DELETE_TODO'; const SELECT_TODO = 'SELECT_TODO'; /** * Actions & Action types */ interface CreateTodoActionType { type: typeof CREATE_TODO; payload: Todo; } export const createTodoActionCreator = ({ desc, }: { desc: string; }): CreateTodoActionType => ({ type: CREATE_TODO, payload: { id: uuid(), desc, isComplete: false, }, }); interface EditTodoActionType { type: typeof EDIT_TODO; payload: { id: string; desc: string }; } export const editTodoActionCreator = ({ id, desc, }: { id: string; desc: string; }): EditTodoActionType => ({ type: EDIT_TODO, payload: { id, desc }, }); interface ToggleTodoActionType { type: typeof TOGGLE_TODO; payload: { id: string; isComplete: boolean }; } export const toggleTodoActionCreator = ({ id, isComplete, }: { id: string; isComplete: boolean; }): ToggleTodoActionType => ({ type: TOGGLE_TODO, payload: { id, isComplete }, }); interface DeleteTodoActionType { type: typeof DELETE_TODO; payload: { id: string }; } export const deleteTodoActionCreator = ({ id, }: { id: string; }): DeleteTodoActionType => ({ type: DELETE_TODO, payload: { id }, }); interface SelectTodoActionType { type: typeof SELECT_TODO; payload: { id: string }; } export const selectTodoActionCreator = ({ id, }: { id: string; }): SelectTodoActionType => ({ type: SELECT_TODO, payload: { id }, }); /** * Reducers */ const todosInitialState: Todo[] = [ { id: uuid(), desc: 'Learn React', isComplete: true, }, { id: uuid(), desc: 'Learn Redux', isComplete: true, }, { id: uuid(), desc: 'Learn Redux-ToolKit', isComplete: false, }, ]; type TodoActionTypes = | CreateTodoActionType | EditTodoActionType | ToggleTodoActionType | DeleteTodoActionType; const todosReducer = ( state: Todo[] = todosInitialState, action: TodoActionTypes ) => { switch (action.type) { case CREATE_TODO: { const { payload } = action; return [...state, payload]; } case EDIT_TODO: { const { payload } = action; return state.map((todo) => todo.id === payload.id ? { ...todo, desc: payload.desc } : todo ); } case TOGGLE_TODO: { const { payload } = action; return state.map((todo) => todo.id === payload.id ? { ...todo, isComplete: payload.isComplete } : todo ); } case DELETE_TODO: { const { payload } = action; return state.filter((todo) => todo.id !== payload.id); } default: { return state; } } }; type SelectedTodoActionTypes = SelectTodoActionType; const selectedTodoReducer = ( state: string | null = null, action: SelectedTodoActionTypes ) => { switch (action.type) { case SELECT_TODO: { const { payload } = action; return payload.id; } default: { return state; } } }; const counterReducer = (state: number = 0, action: TodoActionTypes) => { switch (action.type) { case CREATE_TODO: { return state + 1; } case EDIT_TODO: { return state + 1; } case TOGGLE_TODO: { return state + 1; } case DELETE_TODO: { return state + 1; } default: { return state; } } }; const reducers = combineReducers({ todos: todosReducer, selectedTodo: selectedTodoReducer, counter: counterReducer, }); /** * Store */ export default createStore( reducers, composeWithDevTools(applyMiddleware(thunk, logger)) );
redux-rtk.ts
import { configureStore, createSlice, getDefaultMiddleware, PayloadAction, } from '@reduxjs/toolkit'; import { v1 as uuid } from 'uuid'; import logger from 'redux-logger'; export interface Todo { id: string; desc: string; isComplete: boolean; } export interface State { todos: Todo[]; selectedTodo: string | null; counter: number; } const todosInitialState: Todo[] = [ { id: uuid(), desc: 'Learn React', isComplete: true, }, { id: uuid(), desc: 'Learn Redux', isComplete: true, }, { id: uuid(), desc: 'Learn Redux-ToolKit', isComplete: false, }, ]; const todosSlice = createSlice({ name: 'todos', initialState: todosInitialState, reducers: { create: { reducer: ( state, { payload, }: PayloadAction<{ id: string; desc: string; isComplete: boolean }> ) => { state.push(payload); }, prepare: ({ desc }: { desc: string }) => ({ payload: { id: uuid(), desc, isComplete: false, }, }), }, edit: (state, { payload }: PayloadAction<{ id: string; desc: string }>) => { const matchedTodo = state.find((todo) => todo.id === payload.id); if (typeof matchedTodo !== 'undefined') { matchedTodo.desc = payload.desc; } }, toggle: ( state, { payload }: PayloadAction<{ id: string; isComplete: boolean }> ) => { const matchedTodo = state.find((todo) => todo.id === payload.id); if (typeof matchedTodo !== 'undefined') { matchedTodo.isComplete = payload.isComplete; } }, remove: (state, { payload }: PayloadAction<{ id: string }>) => { const index = state.findIndex((todo) => todo.id === payload.id); if (index !== -1) { state.splice(index, 1); } }, }, }); const selectedTodoSlice = createSlice({ name: 'selectedTodo', initialState: '', reducers: { select: (state, { payload }: PayloadAction<{ id: string }>) => payload.id, }, }); const counterSlice = createSlice({ name: 'counter', initialState: 0, reducers: {}, extraReducers: { [todosSlice.actions.create.type]: (state) => state + 1, [todosSlice.actions.edit.type]: (state) => state + 1, [todosSlice.actions.toggle.type]: (state) => state + 1, [todosSlice.actions.remove.type]: (state) => state + 1, }, }); export const { create: createTodoActionCreator, edit: editTodoActionCreator, toggle: toggleTodoActionCreator, remove: deleteTodoActionCreator, } = todosSlice.actions; export const { select: selectTodoActionCreator } = selectedTodoSlice.actions; const reducer = { todos: todosSlice.reducer, selectedTodo: selectedTodoSlice.reducer, counter: counterSlice.reducer, }; const middleware = [...getDefaultMiddleware(), logger]; export default configureStore({ reducer, middleware, });
RTKメモ
- RTKをreactプロジェクトに導入する場合に必要な最低のパッケージは、
@reduxjs/toolkit
とreact-redux
だけでこと足りる。redux
もミドルウェアも内包しているので
(RTKのpackage.json
を見ればわかる)
- RTKでの非同期処理は
redux-thunk
を用いて行われている - RTKでは
immer
を内包しているので、mutableな形でstateの更新ができる - RTKでreducer, acitionsなどを記述する際は、大体
createSlice
一本でまとまる - RTKでは
initialState
のフィールドが必須になっているので、従来のような定義漏れが起こりにくい - RTKでstateがプリミティブ型の場合は、mutateする形ではなく、従来のreduxのかたちでimmutableに書く
- RTKでreducerに渡す際に、initialStateが上記の例のようにimpureな副作用をともなうファンクションなどを含んでいる場合、直下のreducerのメソッド内に、さらに
reducer
とprepare
メソッドを書くことで、扱うことができるようになる
React x TypeScriptの組み合わせミニマルサンプル
こちらのBen Awadのサンプルがよかったのでメモしておく。
ディレクトリ構造
. ├── package.json ├── public │ └── index.html ├── src │ ├── App.tsx │ ├── Counter.tsx │ ├── ReducerExample.tsx │ ├── TextField.tsx │ ├── index.tsx │ └── react-app-env.d.ts ├── tsconfig.json └── yarn.lock
コード
Render propsと型推論の活用
index.tsx
import React from "react"; import ReactDOM from "react-dom"; import App from "./App"; ReactDOM.render(<App />, document.getElementById("root"));
App.tsx
import React from "react"; import { Counter } from "./Counter"; // props // hooks // render props const App: React.FC = () => { return ( <div> <Counter> {({ count, setCount }) => ( <div> {count} <button onClick={() => setCount(count + 1)}>+</button> </div> )} </Counter> </div> ); }; export default App;
Render propsでcounterの実装をしている。
Counter.tsx
import React, { useState } from "react"; interface Props { children: (data: { count: number; setCount: React.Dispatch<React.SetStateAction<number>>; }) => JSX.Element | null; } export const Counter: React.FC<Props> = ({ children }) => { const [count, setCount] = useState(0); return <div>{children({ count, setCount })}</div>; };
children
に関数と引数を渡すというもの。
setCount
のようなセッターの型をマニュアルで記述するのは大変だが、VSCodeのホバー時に型推論をサジェストしてくれるので、それをペーストする。
useReducer
の使用例
ReducerExample.tsx
import React, { useReducer } from "react"; type Actions = | { type: "add"; text: string } | { type: "remove"; idx: number; }; interface Todo { text: string; complete: boolean; } type State = Todo[]; const TodoReducer = (state: State, action: Actions) => { switch (action.type) { case "add": return [...state, { text: action.text, complete: false }]; case "remove": return state.filter((_, i) => action.idx !== i); default: return state; } }; export const ReducerExample: React.FC = () => { const [todos, dispatch] = useReducer(TodoReducer, []); return ( <div> {JSON.stringify(todos)} <button onClick={() => { dispatch({ type: "add", text: "..." }); }} > + </button> </div> ); };
useState
, useRef
の使用例
TextField.tsx
import React, { useState, useRef } from "react"; interface Person { firstName: string; lastName: string; } interface Props { text: string; ok?: boolean; i?: number; fn?: (bob: string) => string; person: Person; handleChange: (event: React.ChangeEvent<HTMLInputElement>) => void; } interface TextNode { text: string; } export const TextField: React.FC<Props> = ({ handleChange }) => { const [count, setCount] = useState<TextNode>({ text: "hello" }); const inputRef = useRef<HTMLInputElement>(null); const divRef = useRef<HTMLDivElement>(null); return ( <div ref={divRef}> <input ref={inputRef} onChange={handleChange} /> </div> ); };
学びの記録『りあクト!』第11章 Redux でグローバルな状態を扱う
useSelector
, useDispatch
を用いたReduxの書き方
ディレクトリ構造
container, presentationalディレクトリで分けて、atomic design風に分ける。
. ├── App.css ├── App.tsx ├── components │ ├── container │ │ ├── molecules │ │ │ └── ColorfulBeads.tsx │ │ └── organisms │ │ └── CounterBoard.tsx │ └── presentational │ ├── molecules │ │ ├── ColorfulBeads.css │ │ └── ColorfulBeads.tsx │ └── organisms │ ├── CounterBoard.css │ └── CounterBoard.tsx ├── index.tsx └── redux ├── actions.ts └── reducer.ts
コード
index.tsx
import React from 'react' import ReactDOM from 'react-dom' import { createStore } from 'redux' import { Provider } from 'react-redux' import { counterReducer, initialState } from './redux/reducer' import { App } from './App' import 'semantic-ui-css/semantic.css' const store = createStore(counterReducer, initialState) ReactDOM.render( <Provider store={store}> <App /> </Provider>, document.getElementById('root'), )
App.tsx
import React from 'react' import { EnhancedCounterBoard as CounterBoard } from './components/container/organisms/CounterBoard' import { EnhancedColorfulBeads as ColorfulBeads } from './components/container/molecules/ColorfulBeads' import './App.css' export const App: React.FC = () => ( <div className="container"> <header> <h1>Beads counter</h1> </header> <CounterBoard /> <ColorfulBeads count={0} /> </div> )
App.tsx
にcontainerコンポーネントCounterBoard.tsx
, ColorfulBeads.tsx
を読み込む。
src/components/container/organisms/CounterBoard.tsx
import React from 'react' import { useSelector, useDispatch } from 'react-redux' import { ICounterState } from '../../../redux/reducer' import { decrement, increment, add } from '../../../redux/actions' import { CounterBoard } from '../../presentational/organisms/CounterBoard' export const EnhancedCounterBoard: React.FC = () => { const count = useSelector<ICounterState, number>((state) => state.count) const dispatch = useDispatch() return ( <CounterBoard count={count} decrement={() => dispatch(decrement())} increment={() => dispatch(increment())} add={(amount) => dispatch(add(amount))} /> ) }
src/components/container/molecules/ColorfulBeads.tsx
import React from 'react' import { useSelector } from 'react-redux' import { ICounterState } from '../../../redux/reducer' import { ColorfulBeads } from '../../presentational/molecules/ColorfulBeads' export const EnhancedColorfulBeads: React.FC<{ count: number }> = () => { const count = useSelector<ICounterState, number>((state) => state.count) return <ColorfulBeads count={count} /> }
containerコンポーネント側でuseSelector
, useDispatch
を利用してRedux storeとdispatcherとのやり取りをしている。
Redux actions, reducerは以下のようになっている。
src/redux/actions.ts
export enum CounterActionType { ADD = 'counter/ADD', DECREMENT = 'counter/DECREMENT', INCREMENT = 'counter/INCREMENT', } export interface ICounterActionDecrement { type: CounterActionType.DECREMENT } export interface ICounterActionIncrement { type: CounterActionType.INCREMENT } export interface ICounterActionAdd { type: CounterActionType.ADD amount: number } export const decrement = (): ICounterActionDecrement => ({ type: CounterActionType.DECREMENT, }) export const increment = (): ICounterActionIncrement => ({ type: CounterActionType.INCREMENT, }) export const add = (amount: number): ICounterActionAdd => ({ type: CounterActionType.ADD, amount, }) export type CounterActions = | ReturnType<typeof decrement> | ReturnType<typeof increment> | ReturnType<typeof add>
src/redux/reducer.ts
import { Reducer } from 'redux' import { CounterActions, CounterActionType as Type } from './actions' export interface ICounterState { count: number } export const initialState: ICounterState = { count: 0 } export const counterReducer: Reducer<ICounterState, CounterActions> = ( state: ICounterState = initialState, action: CounterActions, ): ICounterState => { switch (action.type) { case Type.ADD: return { ...state, count: state.count + action.amount, } case Type.DECREMENT: return { ...state, count: state.count - 1, } case Type.INCREMENT: return { ...state, count: state.count + 1, } default: { const _: never = action return state } } }
presentationalコンポーネント
src/components/presentational/organisms/CounterBoard.tsx
import React from 'react' import { Card, Statistic, Button } from 'semantic-ui-react' const BULK_UNIT = 10 interface ICounterBoardProps { count: number decrement: () => void increment: () => void add: (amount: number) => void } export const CounterBoard: React.FC<ICounterBoardProps> = ({ count = 0, decrement, increment, add, }) => ( <Card> <Statistic className="number-board"> <Statistic.Label>count</Statistic.Label> <Statistic.Value>{count}</Statistic.Value> </Statistic> <Card.Content> <div className="ui two buttons"> <Button color="red" onClick={decrement}> -1 </Button> <Button color="green" onClick={increment}> +1 </Button> </div> <div className="fluid-button"> <Button fluid color="grey" onClick={() => add(BULK_UNIT)}> +{BULK_UNIT} </Button> </div> </Card.Content> </Card> )
src/components/presentational/molecules/ColorfulBeads.tsx
import React from 'react' import { SemanticCOLORS, Container, Label } from 'semantic-ui-react' import './ColorfulBeads.css' // equivalent to https://lodash.com/docs/#range // see also https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/from const range = (n: number): number[] => n < 0 ? [] : Array.from(Array(n), (_, i) => i) const colors: SemanticCOLORS[] = [ 'red', 'orange', 'yellow', 'olive', 'green', 'teal', 'blue', 'violet', 'purple', 'pink', 'brown', 'grey', 'black', ] export const ColorfulBeads: React.FC<{ count: number }> = ({ count = 0 }) => ( <Container className="beads-box"> {range(count).map((n) => ( <Label circular color={colors[n % colors.length]} key={n} /> ))} </Container> )
Redux toolkitの導入
Redux toolkitを用いれば、DUCKSパターンを用いたシンプルなRedux構造を考えることができる。
また、Redux toolkit提供メソッドの一つであるcreateSlice
を用いれば、
- initial state
- reducer
- action types
- actions
などをまとめて一つのファイル内に記述できる。
ディレクトリ構造
. ├── App.css ├── App.tsx ├── components │ ├── container │ │ ├── molecules │ │ │ └── ColorfulBeads.tsx │ │ └── organisms │ │ └── CounterBoard.tsx │ └── presentational │ ├── molecules │ │ ├── ColorfulBeads.css │ │ └── ColorfulBeads.tsx │ └── organisms │ ├── CounterBoard.css │ └── CounterBoard.tsx ├── features │ └── counter.ts └── index.tsx
コード
src/index.tsx
import React from 'react' import ReactDOM from 'react-dom' import { configureStore } from '@reduxjs/toolkit' import { Provider } from 'react-redux' import { counterSlice } from './features/counter' import { App } from './App' import 'semantic-ui-css/semantic.css' const store = configureStore({ reducer: counterSlice.reducer }) ReactDOM.render( <Provider store={store}> <App /> </Provider>, document.getElementById('root'), )
src/App.tsx
import React from 'react' import { EnhancedColorfulBeads as ColorfulBeads } from './components/container/molecules/ColorfulBeads' import { EnhancedCounterBoard as CounterBoard } from './components/container/organisms/CounterBoard' import './App.css' export const App: React.FC = () => ( <div className="container"> <header> <h1>Beads counter</h1> </header> <CounterBoard /> <ColorfulBeads count={0} /> </div> )
src/features/counter.ts
counterに関連するreduxコードがこちらのファイルに全てまとまる。
import { createSlice, PayloadAction } from '@reduxjs/toolkit' export interface ICounterSlice { count: number } const initialState: ICounterSlice = { count: 0 } export const counterSlice = createSlice({ name: 'counter', initialState, reducers: { decremented: (state) => ({ ...state, count: state.count - 1 }), incremented: (state) => ({ ...state, count: state.count + 1 }), added: (state, action: PayloadAction<number>) => ({ ...state, count: state.count + action.payload, }), }, })
src/components/container/organisms/CounterBoard.tsx
import React from 'react' import { useDispatch, useSelector } from 'react-redux' import { ICounterSlice, counterSlice } from '../../../features/counter' import { CounterBoard } from '../../presentational/organisms/CounterBoard' export const EnhancedCounterBoard: React.FC = () => { const count = useSelector<ICounterSlice, number>((state) => state.count) const dispatch = useDispatch() const { decremented, incremented, added } = counterSlice.actions return ( <CounterBoard count={count} decrement={() => dispatch(decremented())} increment={() => dispatch(incremented())} add={(amount) => dispatch(added(amount))} /> ) }
src/components/container/molecules/ColorfulBeads.tsx
import React from 'react' import { useSelector } from 'react-redux' import { ColorfulBeads } from '../../presentational/molecules/ColorfulBeads' import { ICounterSlice } from '../../../features/counter' export const EnhancedColorfulBeads: React.FC<{ count: number }> = () => { const count = useSelector<ICounterSlice, number>((state) => state.count) return <ColorfulBeads count={count} /> }
TypeScriptで使いまわしやすいコードを書く
Node.js環境上でCSVファイルを取り込んで何かしらの加工をして吐き出すプログラムを書きながら、使い回しのし易いTypeScriptの書き方を学ぶ。
セットアップ
Packages
yarn add -D @types/node @typescript-eslint/eslint-plugin @typescript-eslint/parser concurrently eslint eslint-config-prettier eslint-config-standard-with-typescript eslint-plugin-import eslint-plugin-node eslint-plugin-promise eslint-plugin-standard nodemon prettier rimraf typescript
npm scripts
"scripts": { "start:build": "tsc -w", "start:run": "nodemon build/index.js", "start": "concurrently yarn:start:*", "clean": "rimraf build" },
CSVファイル
一例としてサッカーの試合結果一覧のCSVデータを利用する。
football.csv
10/08/2018,Man United,Leicester,2,1,H,A Marriner 11/08/2018,Bournemouth,Cardiff,2,0,H,K Friend 11/08/2018,Fulham,Crystal Palace,0,2,A,M Dean 11/08/2018,Huddersfield,Chelsea,0,3,A,C Kavanagh 11/08/2018,Newcastle,Tottenham,1,2,A,M Atkinson 11/08/2018,Watford,Brighton,2,0,H,J Moss 11/08/2018,Wolves,Everton,2,2,D,C Pawson 12/08/2018,Arsenal,Man City,0,2,A,M Oliver 12/08/2018,Liverpool,West Ham,4,0,H,A Taylor 12/08/2018,Southampton,Burnley,0,0,D,G Scott 18/08/2018,Cardiff,Newcastle,0,0,D,C Pawson 18/08/2018,Chelsea,Arsenal,3,2,H,M Atkinson 18/08/2018,Everton,Southampton,2,1,H,L Mason 18/08/2018,Leicester,Wolves,2,0,H,M Dean 18/08/2018,Tottenham,Fulham,3,1,H,A Taylor 18/08/2018,West Ham,Bournemouth,1,2,A,S Attwell 19/08/2018,Brighton,Man United,3,2,H,K Friend 19/08/2018,Burnley,Watford,1,3,A,P Tierney 19/08/2018,Man City,Huddersfield,6,1,H,A Marriner 20/08/2018,Crystal Palace,Liverpool,0,2,A,M Oliver
最初のコード
マンUの試合結果を取得してくるロジックをハードコードしたもの。
src/index.ts
import * as fs from 'fs' const matches = fs .readFileSync('football.csv', { encoding: 'utf-8', }) .split('\n') .map((row: string): string[] => row.split(',')) let manUnitedWins = 0 for (const match of matches) { if (match[1] === 'Man United' && match[5] === 'H') { manUnitedWins++ } else if (match[2] === 'Man United' && match[5] === 'A') { manUnitedWins++ } } console.log(`Man United won ${manUnitedWins} games`)
リファクタ1
enum
を用いるのと、別のロジッククラスを用意してリファクタする。
src/index.ts
import { CsvFileReader } from './CsvFileReader' const reader = new CsvFileReader('football.csv') reader.read() enum MatchResult { HomeWin = 'H', AwayWin = 'A', Draw = 'D', } let manUnitedWins = 0 for (const match of reader.data) { if (match[1] === 'Man United' && match[5] === MatchResult.HomeWin) { manUnitedWins++ } else if (match[2] === 'Man United' && match[5] === MatchResult.AwayWin) { manUnitedWins++ } } console.log(`Man United won ${manUnitedWins} games`)
src/CsvFileReader.ts
import * as fs from 'fs' export class CsvFileReader { data: string[][] = [] constructor(public filename: string) {} read(): void { this.data = fs .readFileSync(this.filename, { encoding: 'utf-8', }) .split('\n') .map((row: string): string[] => row.split(',')) } }
いつenum
を使うべきか?
- Follow near-identical syntax rules as normal objects
- Creates an object with the same keys and values when converted from TS to JS
- Primary goal is to signal to to other engineers that these are all closely related values
- Use whenever we have a small fixed set of values that are all closely related and known at compile time
enum
を導入するかどうかの実例
- カラーピッカーで色指定をするとき:
yes
- 既に決まっていてわかっているから
- 動画配信サイトのカテゴリを指定するとき:
no
- 変わり続けてアップデートされるものだから
- メニュー上のドリンクのサイズ:
yes
- 選択肢が数種類しかないので
- 1750年以降の年数:
no
- 値の数が大きすぎる
- テキストメッセージの既読フラグ:
yes
- 想定しうるステータスが決まっているから
React hooksのミニマルサンプル
リファクタプロセス
hooksを一つずつ導入
useState
カウンターアプリを、まずはstate hookを使って書いてみる。
import React, { useState } from 'react' import ReactDOM from 'react-dom' const Counter: React.FC = () => { const [count, setCount] = useState(0) const reset = (): void => setCount(0) const increment = (): void => setCount((c) => c + 1) return ( <div> <div className="number-board"> <span>count</span> <span>{count}</span> </div> <div className="buttons"> <button type="button" onClick={reset}> Reset </button> <button type="button" onClick={increment}> +1 </button> </div> </div> ) } ReactDOM.render( <React.StrictMode> <Counter /> </React.StrictMode>, document.getElementById('root'), )
useEffect
上記を応用したカウントダウンタイマー。
import React, { useState, useEffect } from 'react' import ReactDOM from 'react-dom' const Timer: React.FC<{ limit: number }> = ({ limit }) => { const [timeLeft, setTimeLeft] = useState(limit) const reset = (): void => setTimeLeft(limit) const tick = (): void => setTimeLeft((t) => t - 1) useEffect(() => { const timerId = setInterval(tick, 1000) return () => clearInterval(timerId) }, []) useEffect(() => { if (timeLeft === 0) setTimeLeft(limit) }, [timeLeft, limit]) return ( <div> <div className="number-board"> <span>time</span> <span>{timeLeft}</span> </div> <div className="buttons"> <button type="button" onClick={reset}> Reset </button> </div> </div> ) } ReactDOM.render( <React.StrictMode> <Timer limit={60} /> </React.StrictMode>, document.getElementById('root'), )
useMemo
メモ化を導入してみる。
import React, { useState, useEffect, useMemo } from 'react' import ReactDOM from 'react-dom' const getPrimes = (maxRange: number): number[] => // @ts-expect-error [...Array(maxRange + 1).keys()].slice(2).filter((n) => { for (let i = 2; i < n; i += 1) { if (n % i === 0) return false } return true }) const Timer: React.FC<{ limit: number }> = ({ limit }) => { const [timeLeft, setTimeLeft] = useState(limit) const primes = useMemo(() => getPrimes(limit), [limit]) const reset = (): void => setTimeLeft(limit) const tick = (): void => setTimeLeft((t) => t - 1) useEffect(() => { const timerId = setInterval(tick, 1000) return () => clearInterval(timerId) }, []) useEffect(() => { if (timeLeft === 0) setTimeLeft(limit) }, [timeLeft, limit]) return ( <div> <div className="number-board"> <span>time</span>{' '} <span style={{ color: primes.includes(timeLeft) ? 'pink' : '' }}> {timeLeft} </span> </div> <div className="buttons"> <button type="button" onClick={reset}> Reset </button> </div> </div> ) } ReactDOM.render( <React.StrictMode> <Timer limit={60} /> </React.StrictMode>, document.getElementById('root'), )
useCallback
setTimeLeft(limit)
を呼んでるところ、reset()
に共通化する。
import React, { useState, useEffect, useMemo } from 'react' import ReactDOM from 'react-dom' const getPrimes = (maxRange: number): number[] => // @ts-expect-error [...Array(maxRange + 1).keys()].slice(2).filter((n) => { for (let i = 2; i < n; i += 1) { if (n % i === 0) return false } return true }) const Timer: React.FC<{ limit: number }> = ({ limit }) => { const [timeLeft, setTimeLeft] = useState(limit) const primes = useMemo(() => getPrimes(limit), [limit]) const reset = (): void => setTimeLeft(limit) const tick = (): void => setTimeLeft((t) => t - 1) useEffect(() => { const timerId = setInterval(tick, 1000) return () => clearInterval(timerId) }, []) useEffect(() => { if (timeLeft === 0) reset() }, [timeLeft, limit]) return ( <div> <div className="number-board"> <span>time</span>{' '} <span style={{ color: primes.includes(timeLeft) ? 'pink' : '' }}> {timeLeft} </span> </div> <div className="buttons"> <button type="button" onClick={reset}> Reset </button> </div> </div> ) } ReactDOM.render( <React.StrictMode> <Timer limit={60} /> </React.StrictMode>, document.getElementById('root'), )
eslint(react-hooks/exhaustive-deps)
を入れていると、勝手にdependencies arrayの中身を正しいものにサジェストしてくれる。
React Hook useEffect has a missing dependency: 'reset'. Either include it or remove the dependency array.
そちらを修正すると、今度はreset
メソッドに下線が引かれてuseCallback()
を使うように促される。
The 'reset' function makes the dependencies of useEffect Hook (at line 26) change on every render. To fix this, wrap the 'reset' definition into its own useCallback() Hook.
これらを修正すると、下記のようになる。
import React, { useState, useEffect, useMemo, useCallback } from 'react' import ReactDOM from 'react-dom' const getPrimes = (maxRange: number): number[] => // @ts-expect-error [...Array(maxRange + 1).keys()].slice(2).filter((n) => { for (let i = 2; i < n; i += 1) { if (n % i === 0) return false } return true }) const Timer: React.FC<{ limit: number }> = ({ limit }) => { const [timeLeft, setTimeLeft] = useState(limit) const primes = useMemo(() => getPrimes(limit), [limit]) const reset = useCallback((): void => setTimeLeft(limit), [limit]) const tick = (): void => setTimeLeft((t) => t - 1) useEffect(() => { const timerId = setInterval(tick, 1000) return () => clearInterval(timerId) }, []) useEffect(() => { if (timeLeft === 0) reset() }, [timeLeft, limit, reset]) return ( <div> <div className="number-board"> <span>time</span>{' '} <span style={{ color: primes.includes(timeLeft) ? 'pink' : '' }}> {timeLeft} </span> </div> <div className="buttons"> <button type="button" onClick={reset}> Reset </button> </div> </div> ) } ReactDOM.render( <React.StrictMode> <Timer limit={60} /> </React.StrictMode>, document.getElementById('root'), )
Custom hookでロジックを分離・再利用
Custom hookを使った上で、containerとpresentational componentに分ける。
App.tsx
import React from 'react' import ReactDOM from 'react-dom' import { useTimer } from './hooks' interface IPropsTimer { timeLeft?: number isPrime?: boolean reset?: () => void } const Timer: React.FC<IPropsTimer> = ({ timeLeft = 0, isPrime = false, reset = () => undefined, }) => { return ( <div> <div className="number-board"> <span>time</span>{' '} <span style={{ color: isPrime ? 'pink' : '' }}>{timeLeft}</span> </div> <div className="buttons"> <button type="button" onClick={reset}> Reset </button> </div> </div> ) } interface IPropsEnhancedTimer { limit: number } const EnhancedTimer: React.FC<IPropsEnhancedTimer> = ({ limit }) => { const [timeLeft, isPrime, reset] = useTimer(limit) return <Timer timeLeft={timeLeft} isPrime={isPrime} reset={reset} /> } ReactDOM.render( <React.StrictMode> <EnhancedTimer limit={60} /> </React.StrictMode>, document.getElementById('root'), )
hooks.ts
import { useState, useEffect, useMemo, useCallback, useRef } from 'react' const getPrimes = (maxRange: number): number[] => // @ts-expect-error [...Array(maxRange + 1).keys()].slice(2).filter((n) => { for (let i = 2; i < n; i += 1) { if (n % i === 0) return false } return true }) export const useTimer = (limit: number): [number, boolean, () => void] => { const [timeLeft, setTimeLeft] = useState(limit) const primes = useMemo(() => getPrimes(limit), [limit]) const timerId = useRef<NodeJS.Timeout | undefined>() const reset = useCallback(() => setTimeLeft(limit), [limit]) const tick = (): void => setTimeLeft((t) => t - 1) useEffect(() => { const clearTimer = (): void => { if (typeof timerId.current === 'undefined') { return } clearInterval(timerId.current) } reset() clearTimer() timerId.current = setInterval(tick, 1000) return clearTimer }, [limit, reset]) useEffect(() => { if (timeLeft === 0) reset() }, [timeLeft, reset]) return [timeLeft, primes.includes(timeLeft), reset] }
TypeScriptのデザインパターン
デザインパターン1
Google Maps JavaScript APIを利用して、Google Mapsのヘルパークラスと、そのヘルパークラスに渡されるインスタンス生成用のクラスを複数用意するパターン。
. ├── Company.ts -- Creates instance to pass to CustomMap.ts ├── CustomMap.ts -- Google Maps API helper class ├── User.ts -- Creates instance to pass to CustomMap.ts └── index.ts -- Entry point
イントロ
tsconfig.json
はこんな感じ。
{ "compilerOptions": { "outDir": "./dist/", "sourceMap": true, "strict": true, "noImplicitReturns": true, "noImplicitAny": true, "module": "es6", "moduleResolution": "node", "target": "es5", "allowJs": true, "allowSyntheticDefaultImports": true }, "include": ["./src/**/*"] }
Google Mapはシンプルにindex.htmlにscriptタグでCDNで読み込む。
<script src="https://maps.googleapis.com/maps/api/js?key=XXXXXXXXXXXX"></script>
経度や緯度を含めたダミー情報を読み込む用にはfaker
というパッケージを使う。
また、Google Maps JavaScript APIを使用する際の型のサポートを行うパッケージも追加する。
yarn add faker && yarn add -D @types/faker @types/googlemaps
コード
ヘルパークラスCustomMap.ts
APIを直接扱うためのクラス。
export interface IMappable { location: { lat: number lng: number } markerContent(): string color: string } export class CustomMap { private googleMap: google.maps.Map constructor(elId: string) { const $mapEl = document.getElementById(elId) if ($mapEl === null) { throw new Error('Map is null or has not been rendered!') } $mapEl.style.minHeight = '500px' this.googleMap = new google.maps.Map($mapEl, { zoom: 1, center: { lat: 0, lng: 0, }, }) } addMarker(mappable: IMappable): void { const marker = new google.maps.Marker({ map: this.googleMap, position: { lat: mappable.location.lat, lng: mappable.location.lng, }, }) marker.addListener('click', () => { const infoWindow = new google.maps.InfoWindow({ content: mappable.markerContent(), }) infoWindow.open(this.googleMap, marker) }) } }
private googleMap: google.maps.Map
インスタンス変数の型をprivate
で指定することで、クラスを使用する際に不用意に書き換えされないようにする。不用意にアクセスして書き換えようとすると、以下のようなエラーが表示される。
(property) CustomMap.googleMap: google.maps.Map<Element> Property 'googleMap' is private and only accessible within class 'CustomMap'.ts(2341)
addMarker(mappable: IMappable): void
Google Map上にマーカーを表示するためのメソッド。 mappable に緯度、経度、マーカーの中身を返す関数を渡す。
インスタンス生成用クラスUser.ts
適当なユーザー名と緯度・経度を生成する。
import faker from 'faker' import { IMappable } from './CustomMap' export class User implements IMappable { name: string location: { lat: number lng: number } color: string = 'userDefaultColor' constructor() { this.name = faker.name.firstName() this.location = { lat: parseFloat(faker.address.latitude()), lng: parseFloat(faker.address.longitude()), } } markerContent(): string { return ` <div> <p>User name: ${this.name}</p> </div> ` } }
インスタンス生成用クラスCompany.ts
適当な会社名と緯度・経度を生成する。
import faker from 'faker' import { IMappable } from './CustomMap' export class Company implements IMappable { companyName: string catchPhrase: string location: { lat: number lng: number } color: string = 'companyDefaultColor' constructor() { this.companyName = faker.company.companyName() this.catchPhrase = faker.company.catchPhrase() this.location = { lat: parseFloat(faker.address.latitude()), lng: parseFloat(faker.address.longitude()), } } markerContent(): string { return ` <div> <p>Company name: ${this.companyName}</p> <small>Catchphrase: ${this.catchPhrase}</small> </div> ` } }
export class User implements IMappable
この書き方で、インスタンス生成用のクラスの補助的なインターフェースのような役割を付与することができる。
export interface IMappable { location: { lat: number lng: number } markerContent(): string color: string }
となっているので、これらのインスタンス変数とメソッドは必須になる。 それ以外はoptionalで必要な場合に指定するかたち。
エントリーポイントindex.ts
import { User } from './User' import { Company } from './Company' import { CustomMap } from './CustomMap' const user = new User() const company = new Company() const customMap = new CustomMap('app') customMap.addMarker(user) customMap.addMarker(company)
書き方メモ
当たり前といえば当たり前だが、典型的なTypeScriptファイルは下記のような構成となる。
- ファイル上部 - クラスを扱うためのインターフェース - ファイル下部 - クラス定義
デザインパターン2
Bubble sortという最も単純なソートのアルゴリズムを利用して、number
, string
, node
(独自number
のコレクション配列) のソートを行う例。
. ├── CharactersCollection.ts -- Sort array of strings ├── LinkedList.ts -- Sort array of collections of numbers ├── NumbersCollection.ts -- Sort array of numbers ├── Sorter.ts -- Common sorting algorithm └── index.ts -- Entry point
イントロ
tsconfig.json
{ "compilerOptions": { "target": "es5", "module": "commonjs", "outDir": "./build", "rootDir": "./src", "strict": true } }
typescript
以外に必要なパッケージをインストール。こちらの例では実行環境はNode上のみ。
yarn add -D concurrently nodemon
npm scripts
concurrently
を使用してtsc
でJSにコンパイルしながらwatchを行うと同時に、nodemon
でリアルタイムにterminal上にprint outする。
"start:build": "tsc -w", "start:run": "nodemon build/index.js", "start": "concurrently yarn:start:*",
Bubble Sort
配列の中を走査しながら前の項目と後ろの項目を比較し、指定のソートを行うというアルゴリズム。コードとしては微妙だが、forループを2重で書くことで実現できる。
for (let i = 0; i < length; i++) { for (let j = 0; j < length - i - 1; j++) { if (this.collection[j] > this.collection[j + 1]) { const leftHand = this.collection[j] this.collection[j] = this.collection[j + 1] this.collection[j + 1] = leftHand } } }
コード
Abstract class Sorter.ts
Abstract classを使用することで、sortロジックのみをコンパクトに抽出して使用することができる。
export abstract class Sorter { abstract length: number abstract compare(leftIndex: number, rightIndex: number): boolean abstract swap(leftIndex: number, rightIndex: number): void sort(): void { const { length } = this for (let i = 0; i < length; i++) { for (let j = 0; j < length - i - 1; j++) { if (this.compare(j, j + 1)) { this.swap(j, j + 1) } } } } }
NumbersCollection.ts
数字の配列をsortする。
import { Sorter } from './Sorter' export class NumbersCollection extends Sorter { constructor(public data: number[]) { super() } get length(): number { return this.data.length } compare(leftIndex: number, rightIndex: number): boolean { return this.data[leftIndex] > this.data[rightIndex] } swap(leftIndex: number, rightIndex: number): void { const leftHand = this.data[leftIndex] this.data[leftIndex] = this.data[rightIndex] this.data[rightIndex] = leftHand } }
CharactersCollection.ts
文字列の配列をsortする。
import { Sorter } from './Sorter' export class CharactersCollection extends Sorter { constructor(public data: string) { super() } get length(): number { return this.data.length } compare(leftIndex: number, rightIndex: number): boolean { return this.data[leftIndex].toLowerCase() > this.data[rightIndex].toLowerCase() } swap(leftIndex: number, rightIndex: number): void { const characters = this.data.split('') const leftHand = characters[leftIndex] characters[leftIndex] = this.data[rightIndex] characters[rightIndex] = leftHand this.data = characters.join('') } }
LinkedList.ts
数字のコレクションの配列をsortする。
import { Sorter } from './Sorter' class Node { next: Node | null = null constructor(public data: number) {} } export class LinkedList extends Sorter { head: Node | null = null add(data: number): void { const node = new Node(data) if (!this.head) { this.head = node return } let tail = this.head while (tail.next) { tail = tail.next } tail.next = node } get length(): number { if (!this.head) { return 0 } let length = 1 let node = this.head while (node.next) { length++ node = node.next } return length } at(index: number): Node { if (!this.head) { throw new Error('Index out of bounds') } let counter = 0 let node: Node | null = this.head while (node) { if (counter === index) { return node } counter++ node = node.next } throw new Error('Index out of bounds') } compare(leftIndex: number, rightIndex: number): boolean { if (!this.head) { throw new Error('List is empty') } return this.at(leftIndex).data > this.at(rightIndex).data } swap(leftIndex: number, rightIndex: number): void { const leftNode = this.at(leftIndex) const rightNode = this.at(rightIndex) const leftHand = leftNode.data leftNode.data = rightNode.data rightNode.data = leftHand } print(): void { if (!this.head) { return } let node: Node | null = this.head while (node) { console.log(node.data) node = node.next } } }
エントリーポイントindex.ts
import { NumbersCollection } from './NumbersCollection' import { CharactersCollection } from './CharactersCollection' import { LinkedList } from './LinkedList' const numbersCollection = new NumbersCollection([10, 3, -5, 0, 10000]) numbersCollection.sort() console.log(numbersCollection.data) const charactersCollection = new CharactersCollection('Xaayb') charactersCollection.sort() console.log(charactersCollection) const linkedList = new LinkedList() linkedList.add(500) linkedList.add(-10) linkedList.add(-3) linkedList.add(4) linkedList.sort() linkedList.print()
👇ログ結果
# numbersCollection [ -5, 0, 3, 10, 10000 ] # charactersCollection CharactersCollection { data: 'aabXy' } # linkedList -10 -3 4 500
書き方メモ
Interfaces
- Sets up a contract between different classes
- Use when we have very different objects that we want to work together
- Promotes loose coupling
Inheritance / Abstract classes
- Sets up a contract between different classes
- Use when we are trying to build up a definition of an object
- Strongly couples classes together
👉抽象化したビジネスロジックを含んだ抽象化クラスを、使用するクラス側ででextendsさせて使用する