Reactアプリの枠組みの雛形を作ってみる

タイトルが「Reactアプリの枠組みの雛形を作ってみる」とありますが、本記事の目的は、以下のような複数のReactライブラリを一緒に使っても、それぞれのライブラリの機能がそこなわれることなく機能することを確認することです。

個人的には、**antd**が豊富なUIコンポネントを提供してくれているので、今後Material-UIに替えて使っていければなーと思っています。（中国発ですが、トランプ制裁とか関係ないですよね）immutable jsがリストから落ちていますが、今後の課題です。

* @loadable/component
* redux
* react-redux
* redux-logger
* redux-thunk
* react-router-dom
* connected-react-router
* antd
* styled-components


**@loadable/component**
Reactの[コード分割](https://ja.reactjs.org/docs/code-splitting.html)を行うライブラリ。バンドルの肥大化対応。[React code splitting made easy.](https://www.smooth-code.com/open-source/loadable-components/)

**redux**
[A predictable state container for JavaScript apps.](https://redux.js.org/)


**react-redux**
[Official React bindings for Redux](https://react-redux.js.org/)
「[React Reduxの概要を理解する](https://qiita.com/sand/items/6d02fa7640dd1589ee3d)」


**redux-logger**
[Logger for Redux。](https://github.com/LogRocket/redux-logger)　reduxのstateログを出力するMiddleware。


**redux-thunk**
[Thunk middleware for Redux.](https://github.com/reduxjs/redux-thunk)
actionとして非同期関数を指定することが可能になります。


**react-router-dom**
[react-router](https://reacttraining.com/react-router/web/guides/quick-start)


**connected-react-router**
[A Redux binding for React Router v4 and v5](https://github.com/supasate/connected-react-router)
 history methods (push, replace, go, goBack, goForward)のdispatch が、 redux-thunk と redux-sagaの両方に互換性を持ちます。
「[react-router v4 と Redux](https://qiita.com/sand/items/51f5abb8adc433930598)」


**antd**
[Ant Design of React](https://ant.design/docs/react/introduce)
ReactのUIライブラリ。豊富なUIコンポネントを比較的簡単に使える。
「[React UI library の antd について (1) - Button](https://qiita.com/sand/items/35422a669e6021cccb4f)」


**styled-components**
[Visual primitives for the component age](https://www.styled-components.com/)
JSでstyleを記述する**CSS in JS**のライブラリ。



**【補足】**
実は本検証の過程で、**redux-form**を**react-router-dom**と一緒に使うと機能しないことが確認できました。**reduxForm()**と**connect()**という2つのHOCで2重にラップした時に、propsが、最終的なコンポーネントにうまく伝わっていかない感じでした。結論としてはredux-formは使わずに、**antd**の**Form.create()**を使って**validate**を行うようにしました。私の検証ミスかもしれませんが、丸一日試行錯誤した結果です。







#１．インストール

以下のコマンドで環境構築OKです。

```
yarn create react-app antd-test
cd antd-test
yarn add @loadable/component
yarn add redux react-redux redux-logger redux-thunk
yarn add react-router-dom connected-react-router
yarn add antd styled-components
```

一応package.jsonも掲載しておきます。


```json:package.json
{
  "name": "antd-test",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "@loadable/component": "^5.10.1",
    "antd": "^3.20.2",
    "connected-react-router": "^6.5.2",
    "react": "^16.8.6",
    "react-dom": "^16.8.6",
    "react-redux": "^7.1.0",
    "react-router-dom": "^5.0.1",
    "react-scripts": "3.0.1",
    "redux": "^4.0.4",
    "redux-logger": "^3.0.6",
    "redux-thunk": "^2.3.0",
    "styled-components": "^4.3.2"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  },
  "eslintConfig": {
    "extends": "react-app"
  },
  "browserslist": {
    "production": [
      ">0.2%",
      "not dead",
      "not op_mini all"
    ],
    "development": [
      "last 1 chrome version",
      "last 1 firefox version",
      "last 1 safari version"
    ]
  }
}
```



#２．画面イメージ

本アプリのページは、ホーム画面とユーザ登録画面、ログイン画面の3つです。ページ遷移も含めて以下に紹介します。


■ページ構成：上からヘッダー、コンテンツ、フッター
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61915/8475db0d-4eab-ce0f-07f2-2256b50184f1.png)


■ユーザ登録：コンテンツ=ユーザ登録画面
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61915/8ca2bb9b-ab3b-c19e-d2ec-6ab50c22f80d.png)

ユーザ登録が成功すると、3秒後にログイン画面に自動遷移します。

■ログイン：コンテンツ=ログイン画面
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61915/51d9d400-f9d0-82e1-3b6e-34136047ce62.png)


ログインが成功すると、3秒後にホーム画面に自動遷移します。

■ホーム：コンテンツ=ホーム画面（初期画面）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61915/dff7eaae-ebb9-cf25-e0ce-69bcf18687f1.png)


#３．ソースツリー

以下がソースファイルのツリーになります。
containersディレクトリが重要です。その中でもLoginとRegisterがメインです。Loadable.jsがimportポイントとなります。index.jsがreact-reduxの**container**であり、Register.jsとLogin.jsが**component**となります。index.jsがReduxの処理を行い、Register.jsとLogin.jsはReduxについては何も知らないことになっています。


```
$ tree src
src
├── App.js
├── actions
│   └── actions.js
├── components
│   ├── Footer.js
│   ├── Header.js
│   └── Wrapper.js
├── containers
│   ├── Home
│   │   ├── Loadable.js
│   │   └── index.js
│   ├── Login
│   │   ├── Loadable.js
│   │   ├── Login.js
│   │   └── index.js
│   ├── NotFoundPage
│   │   ├── Loadable.js
│   │   └── index.js
│   ├── Register
│   │   ├── Loadable.js
│   │   ├── Register.js
│   │   ├── Register.org2
│   │   ├── _Register.new
│   │   ├── _Register.org
│   │   └── index.js
│   └── input.js
├── createStore.js
├── form-style.css
├── index.js
└── reducers
    └── index.js
```




#４．index.js


index.jsで**Redux**と**react-redux**、**connected-react-router**の初期設定を行います。

```JSX:src/index.js
import React from 'react'
import ReactDOM from 'react-dom'
import { Provider } from 'react-redux'
import { ConnectedRouter } from 'connected-react-router';
import createBrowserHistory from 'history/createBrowserHistory';
import App from './App'
import createStore from './createStore'

// connected-react-router - action経由でルーティングが可能、push,replace..
// historyを強化
const history = createBrowserHistory();
const store = createStore(history);

const dest = document.getElementById('root')

let render = () => {
  ReactDOM.hydrate(
    <Provider store={store}>
      <ConnectedRouter history={history}>
        <App />
      </ConnectedRouter>
    </Provider>,
    dest
  )
}

render()
```

**createStore.js**はreduxのオリジナルcreateStore.jsのラッパーです。ReducersとMiddlewareの設定を行います。

```JSX:src/createStore.js
import { createStore as reduxCreateStore, combineReducers, applyMiddleware } from 'redux'
import logger from 'redux-logger'
import thunk from 'redux-thunk'
import { routerMiddleware, connectRouter } from 'connected-react-router'
import * as reducers from './reducers'

// connected-react-router - action経由でルーティングが可能、push,replace..
// createStoreの再定義 - historyを引数で受け、connected-react-routerの利用を抽象化
export default function createStore(history) {
  return reduxCreateStore( // オリジナル createStore の別名
    combineReducers({
      ...reducers,
      router: connectRouter(history)
    }),
    applyMiddleware(
      logger,
      thunk,
      routerMiddleware(history)
    )
  );
}
```


**reducers**の定義です。
Redux storeは次の2つのstateを保持します。

* state.users[]: userオブジェクトの配列
* state.logined: ログインしているuserオブジェクト

```JSX:src/reducers/index.js
export const users = (state = [], action) => {
  switch (action.type) {
    case 'ADD_USER': // *** userを追加
      return [
        ...state,    // *** 分割代入、stateに追加
        {
          email: action.user.email,
          name: action.user.name,
          password: action.user.password
        }
      ]
    default:
      return state
  }
}

export const logined = (state = {}, action) => {
  switch (action.type) {
    case 'ADD_LOGINED_USER': // *** userを追加
      return (
        {
          email: action.user.email,
          name: action.user.name,
          password: action.user.password
        })
    default:
      return state
  }
}
```


**redux-thunk**を使っているので、actionは非同期関数で定義しています。ただし非同期はsetTimeout()で模擬したものです
**connected-react-router**を使っており、提供される**push()**は**redux-thunk**と互換性があります。push()を使って、ユーザ登録成功後にログイン画面へ、ログイン成功後にホーム画面へ、自動リダイレクトしています。

```JSX:src/actions/actions.js
import { push } from 'connected-react-router';

const addUser = user => ({
  type: 'ADD_USER',
  user: user
})

const addLoginedUser = user => ({
  type: 'ADD_LOGINED_USER',
  user: user
})

export const asyncAddUser = values => {
  return (dispatch, getState) => {
    setTimeout( () => {
        dispatch(addUser(values))
        dispatch(push("/login"))
    }, 3000 );
  }
}

export const asyncLogin = values => {
  return (dispatch, getState) => {
    setTimeout( () => {
      const state = getState()
      for (const user of state.users) {
        if( user.email === values.email && user.password === values.password ) {
          console.log("login succeful!!!",values)
          dispatch(addLoginedUser(user))
          dispatch(push("/"))
          return
        }
      }
      console.log("login failed!!!",values)
    }, 3000 );
  }
}
```




#５．App.js

App.jsはこのアプリのメインになります。サイト全体のページ構成を定義します。

* react-routerでRouteを定義します。
* 画面の枠組みを定義して、全体のstyleを実装します

styleは**styled-components**を利用しているほか、antd.cssとform-style.cssを読み込んでいます。


```JSX:App.js
import React, { Component } from 'react'
import styled from 'styled-components'
import { Switch, Route } from 'react-router-dom'

import Home from './containers/Home/Loadable'
import LoginPage from './containers/Login/Loadable'
import RegisterPage from './containers/Register/Loadable'
import NotFoundPage from './containers/NotFoundPage/Loadable'
import Header from './components/Header'
import Footer from './components/Footer'

import 'antd/dist/antd.css';
import './form-style.css';

const AppWrapper = styled.div`
  max-width: calc(768px + 16px * 2);
  margin: 0 auto;
  display: flex;
  min-height: 100%;
  padding: 0 16px;
  flex-direction: column;
  background: papayawhip;
  .btn {
    line-height: 0;
  }
`;

class App extends Component {
  render() {
    return (
      <AppWrapper>
        <Header />
        <Switch>
          <Route exact path="/" component={Home} />
          <Route exact path="/login" component={LoginPage} />
          <Route exact path="/register" component={RegisterPage} />
          <Route path="" component={NotFoundPage} />
        </Switch>
        <Footer />
      </AppWrapper>
    );
  }
}

export default App;
```

form-style.cssはForm画面のstyle記述しています。ユーザ登録画面とログイン画面だけで読み込めばいいのですが、面倒なのでApp.js一か所で読み込んでいます。


```css:src/form-style.css
.form-register-containers {
  width: 100%;
  margin: auto;
  max-width: 400px;
  padding: 50px 10px;
}

.form-register-containers .center {
  text-align: center;
}

.form-register-containers .ant-form-item-label {
  line-height: 1;
}

.form-register-containers .ant-form-item-with-help {
  margin-bottom: 0;
}
```


#６．ユーザ登録

loadableを通して、containerコンポーネントのindex.jsを読み込みます。

```JSX:src/containers/Register/Loadable.js
import loadable from '@loadable/component';
export default loadable(() => import('./index'));
```

index.jsはcontainerコンポーネントとして、**react-redux**の設定を行い、**connect()**でcomponentをラップします。加えて**antd**の**Form.create()**でラップしています。
2つのHOCを使っているのですが、順番に注意してください。

```JSX:src/containers/Register/index.js
import { connect } from 'react-redux'
import { asyncAddUser } from '../../actions/actions'
import Register from './Register';

import { Form } from 'antd';

function mapStateToProps(state) {
  return state
}

function mapDispatchToProps(dispatch) {
  return {
    onSubmit : values => {
        dispatch(asyncAddUser(values))
    }
  }
}

let myRegister = connect(mapStateToProps, mapDispatchToProps)(Register)
myRegister = Form.create({ name: 'register_form' })(myRegister);

export default myRegister
```

antdのFormを使って、ユーザ登録のform画面を定義しています。Form.create()でラッピングしているので、様々なvalidate関数を利用できます。

```JSX:src/
import React from 'react';
import { Form, Input, Icon, Button } from 'antd';

class Register extends React.Component {

  state = {
    confirmDirty: false,
    autoCompleteResult: [],
  };

  componentDidMount() {
    // To disabled submit button at the beginning.
    this.props.form.validateFields();
  }

  handleSubmit = e => {
    console.log(this.props)
    e.preventDefault();
    this.props.form.validateFields((err, values) => {
      if (!err) {
        console.log('Submit OK: ', values);
        this.props.onSubmit( values )
      } else {
        console.log('Submit NG: ', values);
      }
    })
  }

  handleConfirmBlur = e => {
    const { value } = e.target;
    this.setState({ confirmDirty: this.state.confirmDirty || !!value });
  };

  compareToFirstPassword = (rule, value, callback) => {
    const { form } = this.props;
    if (value && value !== form.getFieldValue('password')) {
      callback('Two passwords that you enter is inconsistent!');
    } else {
      callback();
    }
  };

  validateToNextPassword = (rule, value, callback) => {
    const { form } = this.props;
    if (value && this.state.confirmDirty) {
      form.validateFields(['confirm'], { force: true });
    }
    callback();
  };

  render() {
    const { getFieldDecorator, getFieldsError, getFieldError, isFieldTouched } = this.props.form;

    // Only show error after a field is touched.
    const emailError = isFieldTouched('email') && getFieldError('email');
    const nameError = isFieldTouched('name') && getFieldError('name');
    const passwordError = isFieldTouched('password') && getFieldError('password');
    const confirmError = isFieldTouched('confirm') && getFieldError('confirm');
    const buttonDisable = getFieldError('email') || getFieldError('name') || getFieldError('password') || getFieldError('confirm')

    return(
      <Form onSubmit={this.handleSubmit} className="form-register-containers">
        <h1 className="center">
          ユーザ登録
        </h1>

        <Form.Item label="メールアドレス" validateStatus={emailError ? 'error' : ''} help={emailError || ''}>
          {getFieldDecorator('email', {
            rules: [ {type: 'email', message: 'The input is not valid E-mail!',},
                     { required: true, message: 'Please input your email!' }],
            })(
              <Input
                prefix={<Icon type="mail" style={{ color: 'rgba(0,0,0,.25)' }} />}
                placeholder="email"
              />,
          )}
        </Form.Item>

        <Form.Item label="パスワード" validateStatus={passwordError ? 'error' : ''} help={passwordError || ''}>
          {getFieldDecorator('password', {
            rules: [ { required: true, message: 'Please input your password!' },
                     { validator: this.validateToNextPassword,} ],
            })(
              <Input
                prefix={<Icon type="lock" style={{ color: 'rgba(0,0,0,.25)' }} />}
                placeholder="password"
              />,
          )}
        </Form.Item>

        <Form.Item label="確認パスワード" validateStatus={confirmError ? 'error' : ''} help={confirmError || ''}>
          {getFieldDecorator('confirm', {
            rules: [ { required: true, message: 'Please input your confirmPassword!' },
                     { validator: this.compareToFirstPassword,} ],
            })(
              <Input
                prefix={<Icon type="lock" style={{ color: 'rgba(0,0,0,.25)' }} />}
                placeholder="confirmPassword"
                onBlur={this.handleConfirmBlur}
              />,
          )}
        </Form.Item>

        <Form.Item label="名前" validateStatus={nameError ? 'error' : ''} help={nameError || ''}>
          {getFieldDecorator('name', {
            rules: [{ required: true, message: 'Please input your name!' }],
            })(
              <Input
                prefix={<Icon type="user" style={{ color: 'rgba(0,0,0,.25)' }} />}
                placeholder="name"
              />,
          )}
        </Form.Item>

        <Form.Item className="center">
          <Button
            type="primary"
            htmlType="submit"
            className="btn-submit"
            disabled = {buttonDisable}
          >
            ユーザ登録
          </Button>
        </Form.Item>
      </Form>
    )
  }
}

export default Register
```



#７．ログイン

ログイン画面はユーザ登録画面と構成が全く同じです。説明も重複になるので、省略します。


```JSX:src/containers/Login/Loadable.js
import loadable from '@loadable/component';
export default loadable(() => import('./index'));
```

```JSX:src/containers/Login/index.js
import { connect } from 'react-redux'
import { asyncLogin } from '../../actions/actions'
import Login from './Login';

import { Form } from 'antd';

function mapStateToProps(state) {
  return state
}


function mapDispatchToProps(dispatch) {
  return {
    onSubmit : values => {
        dispatch(asyncLogin(values))
    }
  }
}

let myLogin = connect(mapStateToProps, mapDispatchToProps)(Login)
myLogin = Form.create({ name: 'login_form' })(myLogin);

export default myLogin
```



```JSX:src/containers/Login/Login.js
import React from 'react';
import { Form, Input, Icon, Button } from 'antd';

class Login extends React.Component {

  componentDidMount() {
    // To disabled submit button at the beginning.
    this.props.form.validateFields();
  }

  handleSubmit = e => {
    console.log(this.props)
    e.preventDefault();
    this.props.form.validateFields((err, values) => {
      if (!err) {
        console.log('Received values of form: ', values);
        this.props.onSubmit( values )
      }
    })
  }

  render() {
    const { getFieldDecorator, getFieldsError, getFieldError, isFieldTouched } = this.props.form;

    // Only show error after a field is touched.
    const emailError = isFieldTouched('email') && getFieldError('email');
    const passwordError = isFieldTouched('password') && getFieldError('password');
    const buttonDisable = getFieldError('email') || getFieldError('password')

    return(
      <Form onSubmit={this.handleSubmit} className="form-register-containers">
        <h1 className="center">
          ログイン
        </h1>
        <Form.Item label="メールアドレス" validateStatus={emailError ? 'error' : ''} help={emailError || ''}>
          {getFieldDecorator('email', {
            rules: [ {type: 'email', message: 'The input is not valid E-mail!',},
                     { required: true, message: 'Please input your email!' }],
            })(
              <Input
                prefix={<Icon type="mail" style={{ color: 'rgba(0,0,0,.25)' }} />}
                placeholder="email"
              />,
          )}
        </Form.Item>

        <Form.Item label="パスワード" validateStatus={passwordError ? 'error' : ''} help={passwordError || ''}>
          {getFieldDecorator('password', {
            rules: [{ required: true, message: 'Please input your password!' }],
            })(
              <Input
                prefix={<Icon type="lock" style={{ color: 'rgba(0,0,0,.25)' }} />}
                placeholder="password"
              />,
          )}
        </Form.Item>


        <Form.Item className="center">
          <Button
            type="primary"
            htmlType="submit"
            className="btn-submit"
            disabled = {buttonDisable}
          >
            ログイン
          </Button>
        </Form.Item>
      </Form>
    )
  }
}

export default Login
```




#８．ホーム

ホーム画面は特にありません。

```JSX:src/containers/Home/Loadable.js
import loadable from "@loadable/component";
export default loadable(() => import("./index"));
```


```JSX:src/
import React from 'react';
import { Route, Link } from 'react-router-dom';

const Home = () => (
  <div>
    <h1>私のホームページへようこそ　！！！</h1>
    <ul>
      <li><Link to="/login">Login</Link></li>
      <li><Link to="/register">Register</Link></li>
    </ul>
  </div>
);

export default Home;
```


URLで指定されたページが見つからい場合は、以下のcomponentが表示されます。

```JSX:src/containers/NotFoundPage/Loadable.js
import loadable from "@loadable/component";
export default loadable(() => import("./index"));
```

```JSX:src/containers/NotFoundPage/index.js
import React from "react";

export default class NotFound extends React.PureComponent {
  render() {
    return <h1>This is the NotFoundPage Page!</h1>;
  }
}
```





#９．ヘッダー／フッター

ヘッダーとフッターですが、特に説明は不要でしょう。


```JSX:src/components/Header.js
import React from 'react';
import { Link } from "react-router-dom";
import { Breadcrumb } from 'antd';

function Header() {
  return (
    <Breadcrumb>
      <Breadcrumb.Item>
        <Link to="/">ホーム</Link>
      </Breadcrumb.Item>
      <Breadcrumb.Item>
        <Link to="/login">ログイン</Link>
      </Breadcrumb.Item>
      <Breadcrumb.Item>
        <Link to="/register">ユーザ登録</Link>
      </Breadcrumb.Item>
    </Breadcrumb>
  );
}

export default Header;
```

```JSX:src/
import React from 'react';
import Wrapper from './Wrapper';

function Footer() {
  return (
    <Wrapper>
      <section>Footer footer Footer!!!</section>
    </Wrapper>
  );
}

export default Footer;
```




```JSX:src/components/Wrapper.js
import styled from 'styled-components';

const Wrapper = styled.footer`
  display: flex;
  justify-content: space-between;
  padding: 3em 0;
  border-top: 1px solid #666;
`;

export default Wrapper;
```


今回は以上です。