create-react-app で TypeScript + Redux + Material UI のプロジェクトのベースを作る
毎度プロジェクトを作る度に調べてる気がするので、手順をまとめておく。
やりたいこと
- create-react-app で React のプロジェクトを作成する
導入ライブラリなど
- TypeScript
- Redux + Redux Toolkit
- eslint
- prettier
環境
- Mac
- Node v15
- npm v7
- Visual Studio Code (eslint, prettier, editorconfig の拡張機能導入済み)
プロジェクトを作成して VSCode を起動
npx create-react-app --template typescript <project_name>
cd <project_name>
code .
prettier 関連の設定
npm install --save-dev prettier eslint-config-prettier eslint-plugin-prettier
touch .prettierrc.js
module.exports = {
trailingComma: "es5",
tabWidth: 4,
printWidth: 100,
singleQuote: true,
};
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest",
+ "plugin:@typescript-eslint/recommended",
+ "plugin:prettier/recommended"
]
},
最近は eslint と prettier を連携しないのが推奨っぽいので
eslint-config-prettier eslint-plugin-prettier
は不要
npm install --save-dev prettier
editorconfigの設定
touch .editorconfig
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 4
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
VSCodeの設定変更。
- VSCode で
src/App.tsx
を開く - VSCode のフッター部分、TypeScript のバージョン表記 (2021-02-16時点で
4.1.5
) をクリック -
TypeScript のバージョンを選択... [現在=4.1.5]
をクリック -
ワークスペースのバージョンを使用
をクリック
.vscode/settings.json
が生成される。
以下の内容に更新
{
"typescript.tsdk": "node_modules/typescript/lib",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
}
{
"typescript.tsdk": "node_modules/typescript/lib",
"editor.codeActionsOnSave": {
"source.organizeImports": true,
"source.fixAll.eslint": true
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
}
最近は organizeImports
を追加している。
とりあえず、create-react-app で生成されたソースコードを一括整形する。
npx eslint --ext .ts,.tsx --ignore-path .gitignore . --fix
警告が2つ出るはず。
/Users/kazunorikimura/repository/project-name/src/App.tsx
5:1 warning Missing return type on function @typescript-eslint/explicit-module-boundary-types
/Users/kazunorikimura/repository/project-name/src/reportWebVitals.ts
3:25 warning Missing return type on function @typescript-eslint/explicit-module-boundary-types
✖ 2 problems (0 errors, 2 warnings)
どちらも function
の戻り値の型が未指定という内容。
これは手動で修正する。
import React from 'react';
import logo from './logo.svg';
import './App.css';
-function App() {
+function App(): React.ReactElement {
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>
Edit <code>src/App.tsx</code> and save to reload.
</p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
</header>
</div>
);
}
export default App;
import { ReportHandler } from 'web-vitals';
-const reportWebVitals = (onPerfEntry?: ReportHandler) => {
+const reportWebVitals = (onPerfEntry?: ReportHandler): void => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;
npm start
して問題なくビルドされることを確認
蛇足: Web Vitals について
Measuring Performance | Create React App
CreateReactAppにWebVitals計測ライブラリが入ったので試してみた - Qiita
蛇足2: CHANGELOG のテンプレート
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
必要なパッケージのインストール
npm install --save @material-ui/core @reduxjs/toolkit classnames react-redux redux-logger
npm install --save-dev @types/classnames @types/react-redux @types/redux-logger
ログインフォームとホーム画面で構成される、簡単なアプリを用意する。
- ログインフォームにメールアドレスとパスワードを入力してログインボタンをクリックすると Redux の Store にメールアドレスが保持され、ホーム画面に遷移する
-
ホーム画面はヘッダー、サイドバー、コンテンツ領域、フッターで構成本質的でないのでカット
フォルダ/ファイル構成を以下のように変更する。
src
|- components
| |- pages
| | |- Home.test.tsx
| | |- Home.tsx
| | |- Login.test.tsx
| | `- Login.tsx
| |- App.test.tsx
| `- App.tsx
|- models
| `- user.ts
|- stores
| |- index.ts
| `- user.ts
|- index.tsx
|- react-app-env.d.ts
|- reportWebVitals.ts
`- setupTests.ts
export interface UserInfo {
email: string;
}
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { UserInfo } from '../models/user';
export interface UserStore {
current?: UserInfo;
}
const initialState: UserStore = {};
const slice = createSlice({
name: 'user',
initialState,
reducers: {
setUserInfo: (state: UserStore, action: PayloadAction<UserInfo>) => {
return {
...state,
current: action.payload,
};
},
clearUserInfo: (state: UserStore) => {
return {
...state,
current: undefined,
};
},
},
});
export const { setUserInfo, clearUserInfo } = slice.actions;
export default slice;
import { combineReducers, configureStore, getDefaultMiddleware, Store } from '@reduxjs/toolkit';
import logger from 'redux-logger';
import user from './user';
const rootResucer = combineReducers({
user: user.reducer,
});
export type RootState = ReturnType<typeof rootResucer>;
export type ReduxStore = Store<RootState>;
const createStore = (): ReduxStore => {
const middlewares = [...getDefaultMiddleware()];
if (process.env.NODE_ENV === 'development') {
middlewares.push(logger);
}
const store = configureStore({
reducer: rootResucer,
middleware: middlewares,
devTools: process.env.NODE_ENV === 'development',
});
return store;
};
export default createStore;
Loginページ
testable にするために redux との接続部分はコンポーネントから切り離している。
import { Button, Container, makeStyles, TextField, Typography } from '@material-ui/core';
import { ChangeEvent, FormEvent, useState } from 'react';
import { useDispatch } from 'react-redux';
import { setUserInfo } from '../../stores/user';
const useStyles = makeStyles((theme) => ({
root: {
width: '100vw',
height: '100vh',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
},
form: {
//
},
submit: {
marginTop: theme.spacing(3),
},
}));
interface LoginProps {
signIn?: (email: string) => void;
}
interface LoginState {
email: string;
password: string;
}
export const Login: React.FC<LoginProps> = ({ signIn }) => {
const [params, setParams] = useState<Partial<LoginState>>({});
const classes = useStyles();
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
const { name, value } = event.currentTarget;
setParams({
...params,
[name]: value,
});
};
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (event.currentTarget.checkValidity()) {
if (signIn && params.email) {
signIn(params.email);
}
}
};
return (
<div className={classes.root}>
<Container
component="form"
maxWidth="xs"
className={classes.form}
autoComplete="off"
onSubmit={handleSubmit}
>
<Typography variant="h1">Sign In</Typography>
<TextField
variant="outlined"
margin="normal"
required
fullWidth
label="Email"
id="email"
name="email"
value={params.email ?? ''}
onChange={handleChange}
/>
<TextField
variant="outlined"
margin="normal"
required
fullWidth
label="Password"
id="password"
type="password"
name="password"
value={params.password ?? ''}
onChange={handleChange}
/>
<Button
type="submit"
fullWidth
variant="contained"
size="large"
color="primary"
className={classes.submit}
data-testid="signin"
>
Sign In
</Button>
</Container>
</div>
);
};
const ConnectedLogin: React.FC = () => {
const dispatch = useDispatch();
const props: LoginProps = {
signIn: (email: string) => {
dispatch(
setUserInfo({
email,
})
);
},
};
return <Login {...props} />;
};
export default ConnectedLogin;
import { render, screen, fireEvent } from '@testing-library/react';
import { Login } from './Login';
test('render Login page', () => {
const params: Record<string, string> = {
email: 'test@example.com',
password: 'secret',
};
const handleSignIn = jest.fn();
render(<Login signIn={handleSignIn} />);
const email = screen.getByLabelText(/email/i);
const password = screen.getByLabelText(/password/i);
const submit = screen.getByTestId('signin');
// ログインフォームが表示されている
expect(email).toBeInTheDocument();
expect(password).toBeInTheDocument();
expect(submit).toBeInTheDocument();
// ログイン処理が実行される
fireEvent.change(email, { target: { value: params.email } });
fireEvent.change(password, { target: { value: params.password } });
fireEvent.click(submit);
expect(handleSignIn).toHaveBeenCalledTimes(1);
});
Homeページ
import { Button, makeStyles, Paper, Typography } from '@material-ui/core';
import { useDispatch, useSelector } from 'react-redux';
import { UserInfo } from '../../models/user';
import { RootState } from '../../stores';
import { clearUserInfo } from '../../stores/user';
const useStyles = makeStyles((theme) => ({
root: {
margin: theme.spacing(2),
padding: theme.spacing(3),
},
}));
interface HomeProps {
user?: UserInfo;
signOut?: VoidFunction;
}
export const Home: React.FC<HomeProps> = ({ user, signOut }) => {
const classes = useStyles();
return (
<Paper className={classes.root}>
<Typography variant="h1">Home</Typography>
{user && <Typography variant="body1">{user.email}</Typography>}
<Button variant="contained" color="primary" data-testid="signout" onClick={signOut}>
Sign Out
</Button>
</Paper>
);
};
const ConnectedHome: React.FC = () => {
const user = useSelector((state: RootState) => state.user);
const dispatch = useDispatch();
const props: HomeProps = {
user: user.current,
signOut: () => dispatch(clearUserInfo()),
};
return <Home {...props} />;
};
export default ConnectedHome;
import { render, screen, fireEvent } from '@testing-library/react';
import { UserInfo } from '../../models/user';
import { Home } from './Home';
test('render Home page', () => {
const user: UserInfo = {
email: 'test@example.com',
};
const handleSignOut = jest.fn();
render(<Home user={user} signOut={handleSignOut} />);
// ログインユーザーのメールアドレスが表示されている
const node = screen.getByText(new RegExp(user.email));
expect(node).toBeInTheDocument();
// SignOutボタンのクリックで signOut が呼ばれる
const button = screen.getByTestId('signout');
fireEvent.click(button);
expect(handleSignOut).toHaveBeenCalledTimes(1);
});
Appコンポーネント
import { useSelector } from 'react-redux';
import { UserInfo } from '../models/user';
import { RootState } from '../stores';
import Home from './pages/Home';
import Login from './pages/Login';
interface AppProps {
user?: UserInfo;
}
export const App: React.FC<AppProps> = ({ user }) => {
return <>{user ? <Home /> : <Login />}</>;
};
const ConnectedApp: React.FC = () => {
const user = useSelector((state: RootState) => state.user);
const props: AppProps = {
user: user.current,
};
return <App {...props} />;
};
export default ConnectedApp;
App.test.tsx
では実際に redux と接続した際に期待した通りの挙動となるかを確認している。
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { Provider } from 'react-redux';
import createStore from '../stores';
import App from './App';
test('render App', async () => {
const params: Record<string, string> = {
email: 'test@example.com',
password: 'secret',
};
const store = createStore();
render(
<Provider store={store}>
<App />
</Provider>
);
// ログインフォームが表示されている
const title = screen.getAllByText('Sign In');
title.forEach((e) => {
expect(e).toBeInTheDocument();
});
const email = screen.getByLabelText(/email/i);
const password = screen.getByLabelText(/password/i);
const submit = screen.getByTestId('signin');
// ログイン処理
fireEvent.change(email, { target: { value: params.email } });
fireEvent.change(password, { target: { value: params.password } });
fireEvent.click(submit);
// Home に遷移
await waitFor(() => {
expect(screen.getByText('Home')).toBeInTheDocument();
});
});
index.tsx
で redux の store の生成、material ui の theme の生成を行っている。
巨大になりすぎる場合はそれぞれ別コンポーネントに切り出すことを考慮する。
material ui を使っていると、StrictMode が様々なタイミングで警告を出してくる。
正直邪魔だが、StrictMode が役立つこともあるかもしれないので残している。
import React from 'react';
import ReactDOM from 'react-dom';
import { createMuiTheme, CssBaseline, ThemeProvider } from '@material-ui/core';
import { Provider } from 'react-redux';
import App from './components/App';
import reportWebVitals from './reportWebVitals';
import createStore from './stores';
const store = createStore();
const theme = createMuiTheme({
overrides: {
MuiCssBaseline: {
'@global': {
html: {
margin: 0,
padding: 0,
},
body: {
margin: 0,
padding: 0,
},
},
},
},
});
ReactDOM.render(
<React.StrictMode>
<CssBaseline />
<Provider store={store}>
<ThemeProvider theme={theme}>
<App />
</ThemeProvider>
</Provider>
</React.StrictMode>,
document.getElementById('root')
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
createMuiTheme
は非推奨なので createTheme
に置き換える
import React from 'react';
import ReactDOM from 'react-dom';
import { createTheme, CssBaseline, ThemeProvider } from '@material-ui/core';
import { Provider } from 'react-redux';
import App from './components/App';
import reportWebVitals from './reportWebVitals';
import createStore from './stores';
const store = createStore();
const theme = createTheme({
overrides: {
MuiCssBaseline: {
'@global': {
html: {
margin: 0,
padding: 0,
},
body: {
margin: 0,
padding: 0,
},
},
},
},
});
ReactDOM.render(
<React.StrictMode>
<CssBaseline />
<Provider store={store}>
<ThemeProvider theme={theme}>
<App />
</ThemeProvider>
</Provider>
</React.StrictMode>,
document.getElementById('root')
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
最後に material ui のために viewport と webfont の指定を行う。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="minimum-scale=1, initial-scale=1, width=device-width" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Web site created using create-react-app" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>React App</title>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"
/>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>
npm test
でテストが実行され、すべてパスすることを確認。
npm start
でビルドが成功し、画面が表示されることを確認。
ここまで行ったリポジトリ