Typescript React Patternを再確認

TypescriptでのReactアプリケーション開発において、
パターン的なものを一旦個人的な備忘録としてまとめておく。
登録・削除機能のみの簡単なアプリを作ることにする。

Redux

Reactを使う場合の多くにおいて状態管理はReduxを使うことが主なので、
まずはその周りをまとめておく。

Action Creator, Action

ActionTypesはEnumを使う。

1
2
3
4
5
6
7
8
9
// Before Typescript2.4
const ADD_TODO = 'ADD_TODO'
const REMOVE_TODO = 'REMOVE_TODO'

// Current
enum ActionTypes {
ADD_TODO = 'ADD_TODO',
REMOVE_TODO = 'REMOVE_TODO'
}

Typescript2.4からstring Enumsがサポートされてるので、ActionTypesをEnumのメンバーにまとめて定義しておくことで型安全を確保できそう。

Action Creatorは単にActionと呼ばれるObject形式のデータを返す関数であることなので

1
2
3
4
5
6
7
type Action = ReturnType<typeof ActionCreator>
const ActionCreator = (data: unknown) => (
{
type: 'ACTION_TYPE`,
payload: { ... }
}
)

という感じでTypescript2.8からサポートされたConditional Typesを使って表現できる。

1
type AwesomeAction = ReturnType<T>

ReturnTypeは型引数が関数型の場合、その戻り値を表す型となるので、
Action Creatorを定義し、typeof演算子にて各アクションの型情報を生成する。
こうすることで型宣言は型推論を使って実際の実装から得ることができる。

今回は追加と削除のAction Creatorを作成する。
併せてActionの共用体のaliasを作成しReducerの処理周りで使う。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type AddTodoAction = ReturnType<typeof addTodoActionCreator>
const addTodoActionCreator = (todo: string) => ({
type: ActionTypes.ADD_TODO,
payload: {
todo
},
})

type RemoveTodoAction = ReturnType<typeof removeTodoActionCreator>
const removeTodoActionCreator = (id: number) => ({
type: ActionTypes.REMOVE_TODO,
payload: {
id
},
})

// Union Types http://www.typescriptlang.org/docs/handbook/advanced-types.html#union-types
type TodoAction = AddTodoAction | RemoveTodoAction

余談だが抽象的な型表現だと今回のActionの型表現は以下のようになる

1
2
3
4
interface AppActionWithPayload <T extends string, P extends {} = {}> {
type: T
payload: { [K in keyof P]: P[K] }
}

Reducer

Reducerも同じくデフォルトの状態の定義から型情報を取得する。
またreducerの返却値はObject.assignでもいいが、spread operator(スプレッド演算子)も利用できる。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const defaultState: { [index:string]: Array<string> } = { todos: [] }
type TodoState = typeof defaultState
// Reducer
const reducer = (state: TodoState = defaultState, action: TodoAction): TodoState => {
let todos: string[];
switch (action.type) {
case ActionTypes.ADD_TODO:
todos = state.todos.concat(action.payload.todo)
return {...state, todos}
case ActionTypes.REMOVE_TODO:
todos = state.todos.filter((_, i) => i !== action.payload.id )
return {...state, todos}
default:
return {...state}
}
}

あとはStoreを作ればいい

1
const Store = createStore(reducer)

ここまででredux周りとしては終わり。
ReactのCompnentと連結していく

React

作成するアプリはイメージとして以下のようなhtml構造を期待している

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<div id="root">
<!-- input field component -->
<input type="text" id="todo" name="todo" placeholder="Please input todo">
<!-- add button component -->
<button>Add Todo</button>
<!-- todo list component -->
<ul>
<!-- todo item component -->
<li class="todo-item">
<span class="todo-content">todo</span>
<!-- delete button component -->
<button id="0">Delete Todo</button>
</li>
</ul>
</div>

Container Components

ContainerとなるCompoentクラスの定義

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
type AppProps = {
todos: string[]
addTodo: (event: React.MouseEvent<HTMLButtonElement>) => void
removeTodo: (event: React.ChangeEvent<HTMLInputElement>) => void
changeHandler: (event: React.ChangeEvent<HTMLInputElement>) => void
setTextInputFieldRef: (element: HTMLInputElement) => void
} & TodoActionCreators

const initialState = {
input: ''
}

// <1>
type AppState = Readonly<typeof initialState>

class App extends React.Component<AppProps, AppState> {
// <2>
readonly state = initialState
constructor(props: AppProps) {
super(props)
this.addTodo = this.addTodo.bind(this)
this.removeTodo = this.removeTodo.bind(this)
this.changeHandler = this.changeHandler.bind(this)
this.textInputFieldRef = React.createRef()
}

private changeHandler(event: React.ChangeEvent<HTMLInputElement>) {
event.preventDefault()
this.setState({input: event.target.value})
}

private addTodo(event: React.MouseEvent<HTMLButtonElement>) {
event.preventDefault()
this.props.add(this.state.input)
this.setState({input: ''})
this.textInputFieldRef.current!.value = ''
}

private removeTodo(event: React.MouseEvent<HTMLButtonElement>) {
event.preventDefault()
const element = event.target as HTMLElement
this.props.remove(parseInt(element.id, 10))
}

render() {
const {todos} = this.props
return (
<>
<>
<TodoInputField
handleChange={this.changeHandler}
/>
<AddTodoButton handleClick={this.addTodo} />
</>
<ul>
{todos.map((todo:string, index: number) => {
return <TodoItem key={index}>
<TodoContent todoContent={todo} />
<DeleteTodoButton
handleClick={this.removeTodo}
id={index.toString()}
/>
</TodoItem>
})}
</ul>
</>
)
}
}

Container ComppnentはstatefullなのでinitialStateが必要になる。
<1>のように実際の実装からtypeof 演算子で実装から型情報を得る。

ReactではstateはsetStateでのみ変更する。
そのため、stateを直接変更できないよう<2>のようにstateをreadonly修飾子でイミュータブルにしておく。併せて型情報もreadonlyにしている。

Presentational Component

PresentationのComponentはstatelessなことが多い。これは基本的にStateless Functional Componentを利用する。

input filed componentに関して

1
2
3
4
5
6
7
8
9
10
const defaultTodoInputField = {
id: 'todo',
placeholder: 'Please input todo',
name: 'todo',
}
type DefaultTodoInputField = typeof defaultTodoInputField
type TodoInputFieldWithDefaltProps = {
handleChange(event: React.ChangeEvent<HTMLInputElement>): void
forwardRef: React.RefObject<HTMLInputElement>
} & DefaultTodoInputField

defaultTodoInputFieldの型情報はTypeScriptの型推論に任せられる。
実際の実装からtypeof演算子にて型情報を得ることができる。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const TodoInputField: React.SFC<TodoInputFieldWithDefaltProps> = (props) => {
const {id, name, placeholder, handleChange, forwardRef} = props
return (
<input
type="text"
id={id}
name={name}
placeholder={placeholder}
onChange={handleChange}
ref={forwardRef}
/>
)
}
// デフォルト値の設定
TodoInputField.defaultProps = defaultTodoInputField

defaultProps

TypeScript3.0からはJSX syntaxでdefaultPropsプロパティを正しく利用できるようになっているので、その恩恵を受けて!(Non-null assertion operator)を利用する必要はない。

Default props in ES6 class syntax

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// これまでは`defaultProps`プロパティとjsxのレンダリングの関連性を
// サポートしてなかったので`!`でnot-nullをasserしていた
type Props { name?: string }
class Greet extends React.Component<Props> {
static defaultProps = { name: "world"}
render() {
return (
const { name } = this.props;
return <div>Hello ${name!.toUpperCase()}</div>
)
}
}
const Greet: React.SFC<{ name = "world" }: Props = (props) => (
<div>Hello ${name!.toUpperCase()}</div>
)

// Typescript3.0からはJSX Syntaxで`defaultProps`を正しく解釈できるので
// !は必要なくなった
class Greet extends React.Component<Props> {
static defaultProps = { name: "world"}
render() {
return (
const { name } = this.props;
return <div>Hello ${name.toUpperCase()}</div>
)
}
}

const Greet: React.SFC<{ name = "world" }: Props = (props) => (
<div>Hello ${name.toUpperCase()}</div>
)

Refs & Forwarding Refs

Componentのマウントのタイミングinputフォームにフォーカスしたり、
フォームの追加ボタン押下後にフォームの入力値を空にするために、
今回はRefを使って要素にアクセスようにしたい。

Forwarding Refs

Forwarding RefsはReact v16.3.0で追加されたAPIでHOC(Higher-order components)を使って、コンポーネント経由(propsを介して)refsを渡せる。
またrefの作成には同じバージョンで導入されたcreateRef APIを利用する。

そのために App Class を修正する

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
class App extends React.Component<AppProps, AppState> {

// <Forwarding Refs>
private textInputFieldRef: React.RefObject<HTMLInputElement>

readonly state = initialState

constructor(props: AppProps) {
super(props)
this.addTodo = this.addTodo.bind(this)
this.removeTodo = this.removeTodo.bind(this)
this.changeHandler = this.changeHandler.bind(this)
this.textInputFieldRef = React.createRef()
}

componentDidMount() {
// <Forwarding Refsを参照>
this.textInputFieldRef.current!.focus()
}

private changeHandler(event: React.ChangeEvent<HTMLInputElement>) {
event.preventDefault()
this.setState({input: event.target.value})
}

private addTodo(event: React.MouseEvent<HTMLButtonElement>) {
event.preventDefault()
this.props.add(this.state.input)
this.setState({input: ''})
// <Forwarding Refsを参照>
this.textInputFieldRef.current!.value = ''
}

private removeTodo(event: React.MouseEvent<HTMLButtonElement>) {
event.preventDefault()
const element = event.target as HTMLElement
this.props.remove(parseInt(element.id, 10))
}

render() {
const {todos} = this.props
return (
<>
<>
<TodoInputFieldWithForwardedRef
handleChange={this.changeHandler}
forwardRef={this.textInputFieldRef}
/>
<AddTodoButton handleClick={this.addTodo} />
</>
<ul>
{todos.map((todo:string, index: number) => {
return <TodoItem key={index}>
<TodoContent todoContent={todo} />
<DeleteTodoButton
handleClick={this.removeTodo}
id={index.toString()}
/>
</TodoItem>
})}
</ul>
</>
)
}
}

さらにinput fieladのコンポーネントもTodoInputFieldWithForwardedRefを利用する。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const TodoInputField: React.SFC<TodoInputFieldWithDefaltProps> = (props) => {
const {id, name, placeholder, handleChange, forwardRef} = props
return (
<input
type="text"
id={id}
name={name}
placeholder={placeholder}
onChange={handleChange}
ref={forwardRef}
/>
)
}
// デフォルト値の設定
TodoInputField.defaultProps = defaultTodoInputField

// RefsをForwardされたcomponentを合成
type TodoInputFieldWithForwardedRefProps = {
ref?: React.RefObject<TodoInputField>
}

// Todo: detect react props attribute spread types
const TodoInputFieldWithForwardedRef: React.RefForwardingComponent<TodoInputFieldWithForwardedRefProps> = React.forwardRef((props, ref) => (
<TodoInputField {...props} ref={ref} />
)
)

ここまででパターンとして新たに見直した箇所は終わり。

Types or Interface

一般的にはtyped aliaseではなく、interfaceのほうが拡張性あるので、使うべきであるが、Reactのプロジェクトに限れば
外部に公開されるAPIサードパーティーの型情報の場合はinterfaceを使う感じで良さそう。

逆にReactアプリケーションデータやcomponentのprppertyやstateなどはtype aliaseを利用する。

Interface vs Type alias in TypeScript 2.7にある、
Componentの拡張はHOCなどのパターンで行うので、interfaceでの継承などはあまり必要ない。という感じの説明がしっくりきた。
また、type aliaseを利用することで、意図しない型情報のマージを避けることもできる点もあるかな。

参考にしたサイト

code

https://github.com/kazu69/Scripts_Notes/tree/master/react/typescript-react-pattern

Comments