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アプリの3画面で構成されます。
ホームはログインしていない場合はログインページへのリンクを、ログインしている場合は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から非同期で値を取得し、ユーザ名とパスワードが合致した場合にログイン済みとして処理します。
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モジュール。こちらは非同期処理を行わないためシンプルです。
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もミドルウェアとして登録します。
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配下にコンポーネントを作成していきます。
ホーム
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>
)
}
ログイン
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アプリ
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アプリはログイン済みでなければログインにリダイレクトさせたいので、リダイレクト用のコンポーネントも作成していきます。
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の設定を行っていきます。
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を直接マウントしています。
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でページ遷移、状態管理、非同期処理の動きが確認できるアプリケーションが作成できました。まだまだ勉強不足で拙い箇所もありますが、参考になれば幸いです。