TypeScript + React 覚書

TypeScript + Reactを使用してSPAを構築したので、作業メモと復習を兼ねて投稿します。
シンプルなアプリケーションですが、ページ遷移、状態管理、非同期処理が最低限確認できる構成にしています。

状態管理は選択肢が多く迷いましたが、Redux Toolkitが良さそうだったので使ってみました。
見た目はBootstrapで実装していきます。

# 環境構築
Create React AppのTypeScriptテンプレートを使用します。

```
$ npx create-react-app react-ts-app --template typescript
```

ディレクトリを移動してサーバを起動します。

```
$ cd react-ts-app
$ npm start
```

必要なライブラリをインストールします。
historyのみ、最新版だとエラーになるのでバージョンを指定しています。

```
npm i @reduxjs/toolkit react-redux @types/react-redux react-router-dom @types/react-router-dom connected-react-router history@4.10.1 bootstrap reactstrap @types/reactstrap
```

# アプリケーション概要

ホーム、ログイン、Todoアプリの３画面で構成されます。
ホームはログインしていない場合はログインページへのリンクを、ログインしている場合はTodoアプリへのリンクを表示します。
Todoアプリは登録したタスクをリストで表示し、完了ボタンを押すことでリストから消すことができます。

# モジュールの作成

src配下にmodulesディレクトリを作成し、この中にモジュールを定義していきます。
この構成は、Reduxにおけるディレクトリ構成の一つである「Ducksパターン」を参考にしました。

## Ducksパターン
Ducksパターンは、Reduxに必要なActionType、Reducer、Actionを1つのファイルにまとめて見通しを良くすることが目的ですが、ファイルが肥大化することが欠点と言われています。これを解決するためにRe-Ducksパターンという構成もあリます。
今回は、Redux Toolkitを使用することでActionType、Reducer、Actionをまとめて定義できるので、Ducksパターンで作成していきます。

## currentUserモジュール

ログイン状態に関してはcurrentUserステートのusernameの有無で判断することとします。
ダミーのAPIから非同期で値を取得し、ユーザ名とパスワードが合致した場合にログイン済みとして処理します。

```typescript:src/modules/currentUser.ts
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'
import { push } from 'connected-react-router'

type User = {
  username?: string
  isAuthenticationError?: boolean
}

type UserAuthentication = {
  username?: string
}

type UserCredential = {
  username: string
  password: string
}

type ThunkApiConfig = {
  rejectValue: {
    errorCode: number
    errorMessage: string
  }
}

const initialState: User = {}

// ダミーの認証API
// username/password共にuser1の場合にログイン成功とする
const dummyAuthenticate = ({ username, password }: UserCredential) => {
  return new Promise<UserAuthentication>((resolve, reject) => {
    setTimeout(() => {
      if (username === 'user1' && password === 'user1') {
        resolve({ username: 'User1' })
      } else {
        reject()
      }
    }, 1000)
  })
}

// 非同期通信はcreateSliceで定義できないので、予めcreateAsyncThunkで作成しておく
// createAsyncThunkのジェネリクスは<返り値, 引数, thunkAPI>となる
export const fetchCurrentUser = createAsyncThunk<UserAuthentication, UserCredential, ThunkApiConfig>(
  'currentUser/fetch',
  // 第二引数でthunkAPIを受け取る
  async (userCredential, { dispatch, rejectWithValue }) => {
    try {
      const response = await dummyAuthenticate(userCredential)
      // ページ遷移は副作用なので非同期処理内で発火させる
      if (response.username != null) {
        dispatch(push('/'))
      }
      return response
    } catch (e) {
      return rejectWithValue({
        errorCode: 401,
        errorMessage: 'Unauthorized'
      })
    }
  }
)

// ActionType、Reducer、Actionをまとめて定義
const slice = createSlice({
  name: 'currentUser',
  initialState,
  reducers: {
    setCurrentUser(state, action: PayloadAction<User>) {
      // Reduxでは新しいstateをリターンしなければならないが、ReduxToolkit内では
      // Immerが使用されており、stateを直接変更するようにも記述できる
      state.username = action.payload.username
    }
  },
  extraReducers(builder) {
    // fetchCurrentUserの正常終了で発火
    builder.addCase(fetchCurrentUser.fulfilled, (state, action) => {
      state.username = action.payload.username
      state.isAuthenticationError = false
    })
    // fetchCurrentUser内でrejectWithValue関数が呼ばれると発火
    builder.addCase(fetchCurrentUser.rejected, (state, action) => {
      console.error(action.payload?.errorMessage)
      state.isAuthenticationError = true
    })
  }
})

export const {
  setCurrentUser
} = slice.actions

export const currentUserReducer = slice.reducer

```

## todosモジュール

続いてtodosモジュール。こちらは非同期処理を行わないためシンプルです。

```typescript:src/modules/todos.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit'

type Todo = {
  id: number
  task: string
}

const initialState: Todo[] = []

const slice = createSlice({
  name: 'todos',
  initialState,
  reducers: {
    addTask: (state, action: PayloadAction<Todo>) => {
      const newTodo = {
        id: action.payload.id,
        task: action.payload.task,
      }
      state.push(newTodo)
    },
    removeTask: (state, action: PayloadAction<number>) => {
      return state.filter(todo => todo.id !== action.payload)
    }
  }
})

export const {
  addTask,
  removeTask,
} = slice.actions

export const todosReducer = slice.reducer
```

# Storeの作成

作成したモジュールからReducerをまとめてStoreを作成します。
Connected React Routerもミドルウェアとして登録します。

```typescript:src/store.ts
import { combineReducers, configureStore } from '@reduxjs/toolkit'
import { connectRouter, routerMiddleware } from 'connected-react-router'
import { createBrowserHistory } from 'history'

import { todosReducer } from './modules/todos'
import { currentUserReducer } from './modules/currentUser'

// Connected React Routerで共通のインスタンスを使用する
// 必要があるためエクスポートしておく
export const history = createBrowserHistory()

const reducer = combineReducers({
  router: connectRouter(history),
  todos: todosReducer,
  currentUser: currentUserReducer
})

// useSelectorでの型推論に必要なため、reducerの戻り値型をエクスポート
export type RootState = ReturnType<typeof reducer>

export const store = configureStore({
  reducer,
  middleware(getDefaultMiddleware) {
    return getDefaultMiddleware().concat(routerMiddleware(history))
  }
})
```

# コンポーネントの作成

src/components配下にコンポーネントを作成していきます。

## ホーム

```tsx:src/components/Home.tsx
import React from 'react'
import { useSelector } from 'react-redux'
import { useHistory, Link } from 'react-router-dom'
import { Button, Jumbotron } from 'reactstrap'

import { RootState } from '../store'

export const Home: React.FC = () => {
  const currentUser = useSelector((state: RootState) => state.currentUser)
  const { push } = useHistory()

  const unauthorizedView = (
    <>
      <p>アプリケーションを利用するにはログインして下さい</p>
      <Link to="/login">ログインページへ</Link>
    </>
  )

  const authorizedView = (
    <>
      <p>利用するアプリケーションを選択して下さい</p>
      <Button color="primary" onClick={() => push('/todo')}>
        Todoアプリ
      </Button>
    </>
  )

  return (
    <div>
      <Jumbotron>
        <h1 className="py-2">Home</h1>
        {currentUser.username ? authorizedView : unauthorizedView}
      </Jumbotron>
    </div>
  )
}
```
# ログイン

```tsx:src/components/Login.tsx
import React, { useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { RootState } from '../store'
import { fetchCurrentUser } from '../modules/currentUser'
import { Alert, Button, Form, FormGroup, Input, Label } from 'reactstrap'

export const Login: React.FC = () => {
  const dispatch = useDispatch()
  const currentUser = useSelector((state: RootState) => state.currentUser)
  const [username, setUsername] = useState('')
  const [password, setPassword] = useState('')

  const handleSetUsername = (e: React.ChangeEvent<HTMLInputElement>) => {
    setUsername(e.target.value)
  }

  const handleSetPassword = (e: React.ChangeEvent<HTMLInputElement>) => {
    setPassword(e.target.value)
  }

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    dispatch(fetchCurrentUser({username, password}))
  }

  const authenticationErrorView = <Alert color="danger">認証に失敗しました</Alert>

  return (
    <>
      <h2 className="py-2">ログイン</h2>
      { currentUser.isAuthenticationError && authenticationErrorView}
      <Form onSubmit={handleSubmit}>
        <FormGroup>
          <Label for="usernameInput">ユーザー名</Label>
          <Input type="text" id="usernameInput" value={username} required
            onChange={handleSetUsername}
          />
        </FormGroup>

        <FormGroup>
          <Label for="passwordInput">パスワード</Label>
          <Input id="passwordInput" type="password" value={password} required
            onChange={handleSetPassword}
          />
        </FormGroup>

        <Button color="primary" type="submit">
          ログイン
        </Button>
      </Form>
    </>
  )
}

```

# Todoアプリ

```tsx:src/components/Todo.tsx
import React, { useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { Link } from 'react-router-dom'
import { Alert, Button, InputGroup, InputGroupAddon, Input, ListGroup, ListGroupItem } from 'reactstrap'

import { RootState } from '../store'
import { addTask, removeTask } from '../modules/todos'

export const Todo: React.FC = () => {
  const todos = useSelector((state: RootState) => state.todos)
  const dispatch = useDispatch()
  const [newTask, setNewTask] = useState('')

  const onChangeNewTask = (e: React.ChangeEvent<HTMLInputElement>) => {
    setNewTask(e.currentTarget.value)
  }

  const handleAddTask = () => {
    if (newTask === '') {
      window.alert('タスクを入力して下さい')
      return
    }
    dispatch(addTask({
      id: Date.now(),
      task: newTask
    }))
    setNewTask('')
  };
  const handleRemoveTask = (id: number) => {
    dispatch(removeTask(id))
  }

  return (
    <>
      <h2>Todoアプリ</h2>
      <Link to="/" className="d-block px-2 pt-2 pb-4">Homeへ戻る</Link>
      <InputGroup className="mb-3">
        <Input value={newTask} onChange={onChangeNewTask} />
        <InputGroupAddon addonType="append">
          <Button color="primary" onClick={handleAddTask}>タスクを登録する</Button>
        </InputGroupAddon>
      </InputGroup>
      <ListGroup flush>
        {!todos.length
          ? <Alert color="info">登録されているタスクはありません</Alert>
          : todos.map(({ id, task }) => (
            <ListGroupItem key={id}>
              <Button color="outline-success" onClick={() => handleRemoveTask(id)}>
                完了
              </Button>
              <span className="pl-4">{task}</span>
            </ListGroupItem>
          ))}
      </ListGroup>
    </>
  )
}
```

# PrivateRoute

Todoアプリはログイン済みでなければログインにリダイレクトさせたいので、リダイレクト用のコンポーネントも作成していきます。

```tsx:src/components/PrivateRoute.tsx
import React from 'react'
import { Route, Redirect, RouteProps } from 'react-router-dom'
import { useSelector } from 'react-redux'

import { RootState } from '../store'

export const PrivateRoute: React.FC<RouteProps> = (props) => {
  const currentUser = useSelector((state: RootState) => state.currentUser)
  // usernameプロパティがfalsyの場合、ログインにリダイレクト
  return currentUser.username ? <Route {...props} /> : <Redirect to="/login" />
}
```

# App.tsxに統合

App.tsxでStoreやRouteの設定を行っていきます。

```tsx:src/App.tsx
import React from 'react'
import { Provider } from 'react-redux'
import { ConnectedRouter } from 'connected-react-router'
import { Route, Switch } from 'react-router-dom'
import { Container } from 'reactstrap'

import { store, history } from './store'
import { PrivateRoute } from './components/PrivateRoute'
import { Todo } from './components/Todo'
import { Home } from './components/Home'
import { Login } from './components/Login'

const App: React.FC = () => {
  return (
    <Provider store={store}>
      <ConnectedRouter history={history}>
        <Container className="py-3">
          <Switch>
            <Route exact path="/" component={Home} />
            <Route exact path="/login" component={Login} />
            <PrivateRoute exact path="/todo" component={Todo} />
            <Route render={() => (<h1>Not Found...</h1>)} />
          </Switch>
        </Container>
      </ConnectedRouter>
    </Provider>
  )
}

export default App
```

# index.tsxの修正

index.tsxでBootstrapのCSSを読み込みます。
またReactのStrictモード下でreactstrapがエラーを吐くので、Appを直接マウントしています。

```tsx:src/index.tsx
import 'bootstrap/dist/css/bootstrap.min.css'

import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'

ReactDOM.render(<App />, document.getElementById('root'))
```

# まとめ

以上で、TypeScript + Reactでページ遷移、状態管理、非同期処理の動きが確認できるアプリケーションが作成できました。まだまだ勉強不足で拙い箇所もありますが、参考になれば幸いです。