Closed27

create-react-app で TypeScript + Redux + Material UI のプロジェクトのベースを作る

ピン留めされたアイテム
Kazunori KimuraKazunori Kimura

毎度プロジェクトを作る度に調べてる気がするので、手順をまとめておく。

やりたいこと

  • create-react-app で React のプロジェクトを作成する

導入ライブラリなど

  • TypeScript
  • Redux + Redux Toolkit
  • eslint
  • prettier

環境

  • Mac
  • Node v15
  • npm v7
  • Visual Studio Code (eslint, prettier, editorconfig の拡張機能導入済み)
Kazunori KimuraKazunori Kimura

プロジェクトを作成して VSCode を起動

npx create-react-app --template typescript  <project_name>
cd <project_name>
code .
Kazunori KimuraKazunori Kimura

prettier 関連の設定

npm install --save-dev prettier eslint-config-prettier eslint-plugin-prettier
touch .prettierrc.js
.prettierrc.js
module.exports = {
    trailingComma: "es5",
    tabWidth: 4,
    printWidth: 100,
    singleQuote: true,
};
package.json
    "eslintConfig": {
        "extends": [
            "react-app",
            "react-app/jest",
+           "plugin:@typescript-eslint/recommended",
+           "plugin:prettier/recommended"
        ]
    },
Kazunori KimuraKazunori Kimura

最近は eslint と prettier を連携しないのが推奨っぽいので
eslint-config-prettier eslint-plugin-prettier は不要

npm install --save-dev prettier
Kazunori KimuraKazunori Kimura

editorconfigの設定

touch .editorconfig
.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

Kazunori KimuraKazunori Kimura

VSCodeの設定変更。

  1. VSCode で src/App.tsx を開く
  2. VSCode のフッター部分、TypeScript のバージョン表記 (2021-02-16時点で 4.1.5) をクリック
  3. TypeScript のバージョンを選択... [現在=4.1.5] をクリック
  4. ワークスペースのバージョンを使用 をクリック

.vscode/settings.json が生成される。

以下の内容に更新

.vscode/settings.json
{
    "typescript.tsdk": "node_modules/typescript/lib",
    "editor.codeActionsOnSave": {
        "source.fixAll.eslint": true
    },
    "editor.defaultFormatter": "esbenp.prettier-vscode",
    "editor.formatOnSave": true,
}

Kazunori KimuraKazunori Kimura
{
    "typescript.tsdk": "node_modules/typescript/lib",
    "editor.codeActionsOnSave": {
        "source.organizeImports": true,
        "source.fixAll.eslint": true
    },
    "editor.defaultFormatter": "esbenp.prettier-vscode",
    "editor.formatOnSave": true
}

最近は organizeImports を追加している。

Kazunori KimuraKazunori Kimura

とりあえず、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 の戻り値の型が未指定という内容。
これは手動で修正する。

Kazunori KimuraKazunori Kimura
src/App.tsx
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;

Kazunori KimuraKazunori Kimura
src/reportWebVitals.ts
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;

Kazunori KimuraKazunori Kimura

蛇足2: CHANGELOG のテンプレート

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]
Kazunori KimuraKazunori Kimura

必要なパッケージのインストール

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
Kazunori KimuraKazunori Kimura

ログインフォームとホーム画面で構成される、簡単なアプリを用意する。

  • ログインフォームにメールアドレスとパスワードを入力してログインボタンをクリックすると Redux の Store にメールアドレスが保持され、ホーム画面に遷移する
  • ホーム画面はヘッダー、サイドバー、コンテンツ領域、フッターで構成 本質的でないのでカット
Kazunori KimuraKazunori Kimura

フォルダ/ファイル構成を以下のように変更する。

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
Kazunori KimuraKazunori Kimura
src/stores/user.ts
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;

src/stores/index.ts
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;

Kazunori KimuraKazunori Kimura

Loginページ

testable にするために redux との接続部分はコンポーネントから切り離している。

src/components/pages/Login.tsx
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;

src/components/pages/Login.test.tsx
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);
});

Kazunori KimuraKazunori Kimura

Homeページ

src/components/pages/Home.tsx
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;

src/components/pages/Home.test.tsx
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);
});

Kazunori KimuraKazunori Kimura

Appコンポーネント

src/components/App.tsx
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 と接続した際に期待した通りの挙動となるかを確認している。

src/components/App.test.tsx
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();
    });
});

Kazunori KimuraKazunori Kimura

index.tsx で redux の store の生成、material ui の theme の生成を行っている。
巨大になりすぎる場合はそれぞれ別コンポーネントに切り出すことを考慮する。

material ui を使っていると、StrictMode が様々なタイミングで警告を出してくる。
正直邪魔だが、StrictMode が役立つこともあるかもしれないので残している。

src/index.tsx
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();

Kazunori KimuraKazunori Kimura

createMuiTheme は非推奨なので createTheme に置き換える

index.tsx
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();
Kazunori KimuraKazunori Kimura

最後に material ui のために viewport と webfont の指定を行う。

public/index.html
<!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>

このスクラップは2021/02/19にクローズされました