material-uiでRedux-Formのフォーム画面と確認ダイアログとスナックバーを作ってみた

## はじめに

React＋Reduxアプリケーションにプロフィール編集フォームみたいなものを実装する機会があったのですが、Redux-Formで作って自作でスタイル調整していたが、自身のデザインセンスが微塵もないので、クソみたいな見た目になっていました。

そこでmaterial-uiを適応してみたら、ものすごくアプリっぽいまともな感じになったので、共有したいと思いましたのでまとめてみます。また、保存前に確認するダイアログと保存成功を表示するスナックバーも実装してみたので、こちらも併せて紹介します！！

## 使ったもの

```
"@material-ui/core": "^4.10.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "^4.0.0-alpha.55",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-redux": "^7.2.0",
"react-router-dom": "^5.2.0",
"react-scripts": "3.4.1",
"redux": "^4.0.5",
"redux-form": "^8.3.6",
"redux-thunk": "^2.3.0"
```

## 作ったもの

デモは[こちら](https://react-reduxform-materialui.netlify.app/)

プロフィール編集フォーム

![2020-06-07_23h02_45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/619552/b466a4ac-389a-a7f8-3c99-07730fd0eef2.png)

保存確認ダイアログ

![2020-06-07_23h03_47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/619552/05caa661-bbd8-042d-782e-61715caedf38.png)

保存成功を表示するスナックバー
![2020-06-07_23h03_56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/619552/65997d9a-adea-fafb-e86d-983e09fd4668.png)

## プロジェクト作成

create-react-appでReactのプロジェクトを作成して、上記のとおり必要なパッケージをインストールします。(react以外)

```
create-react-app app
npm install --save 
```

## src/index.js

まずReact+Reduxの基本となる部分を実装します。
redux-thunkを使用しています。

```js:src/index.js
import React from "react";
import ReactDOM from "react-dom";
import { createStore, applyMiddleware } from "redux";
import { Provider } from "react-redux";
import thunk from "redux-thunk";
import reducer from "./reducers";

import App from "./App";

const enhancer = applyMiddleware(thunk);

const store = createStore(reducer, enhancer);

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById("root")
);
```

## src/actions/index.js

アクションクリエーターを実装します。
実際はAPIで通信してデータのやりとりをしますが、ここでは割愛します。

`getSelectData`ではセレクトボックスの選択肢用の役職のデータをデータベースからもってきます。
`changeProfile`は保存するデータを送信する処理です。

```js:src/actions/index.js
export const GET_SELECT_DATA = "GET_SELECT_DATA";
export const CHANGE_PROFILE = "CHANGE_PROFILE";

export const getSelectData = () => (dispatch) => {
  // 本当はAPIで通信してデータを持ってくるが、割愛して適当に配列のデータを返す
  const response = {
    data: ["一般社員", "係長", "課長", "部長", "役員", "社長"],
  };
  dispatch({ type: GET_SELECT_DATA, response });
};

export const changeProfile = (values) => (dispatch) => {
  // 本当はAPIで通信して保存するが、割愛して成功を返す
  const response = {
    data: "success",
  };
  dispatch({ type: CHANGE_PROFILE, response });
};
```

## src/reducers/select.js

セレクトボックスの選択肢用のデータを返すreducerを実装。

```js:src/reducers/index.js
import { GET_SELECT_DATA } from "../actions";

export const select = (events = {}, action) => {
  switch (action.type) {
    case GET_SELECT_DATA:
      return action.response.data;
    default:
      return events;
  }
};
```

## src/reducers/change.js

保存処理の成否を返すreducerを実装。

```js:src/reducers/change.js
import { CHANGE_PROFILE } from "../actions";

export const change = (events = {}, action) => {
  switch (action.type) {
    case CHANGE_PROFILE:
      return action.response.data;
    default:
      return events;
  }
};

```

## src/reducers/index.js

`combineReducers `で分割していた`reducer`を結合する。
redux-formもここで結合。

```js:src/reducers/index.js
import { combineReducers } from "redux";
import { reducer as form } from "redux-form";
import { select } from "./select";
import { change } from "./change";

export default combineReducers({
  select,
  change,
  form,
});
```

## src/components/renderfield.js

テキストフィールドとセレクトボックスの部分をコンポーネント化しておく。

```js:src/components/renderfield.js
import React from "react";
import TextField from "@material-ui/core/TextField";
import Select from "@material-ui/core/Select";

export const RenderField = (props) => {
  const {
    input,
    label,
    type,
    disabled,
    meta: { touched, error },
  } = props;

  let isError = false;
  if (touched) {
    isError = error === undefined ? false : true;
  }

  return (
    <TextField
      {...input}
      type={type}
      error={isError}
      helperText={touched && error}
      variant="outlined"
      required
      fullWidth
      disabled={disabled}
      label={label}
    />
  );
};

export const RenderSelect = (props) => {
  const {
    input: { value, onChange },
    onFieldChange,
    children,
    label,
  } = props;

  return (
    <Select
      required
      fullWidth
      variant="outlined"
      value={value}
      label={label}
      onChange={(e) => {
        onChange(e.target.value);
        onFieldChange && onFieldChange(e.target.value);
      }}
    >
      {children}
    </Select>
  );
};

```

## src/components/alert.js

確認ダイアログの部分をコンポーネント化しておく。
propsで送信するデータや各種関数を受け取っておく。

```js:src/components/alert.js
import React from "react";
import Dialog from "@material-ui/core/Dialog";
import DialogActions from "@material-ui/core/DialogActions";
import DialogContent from "@material-ui/core/DialogContent";
import DialogContentText from "@material-ui/core/DialogContentText";
import Button from "@material-ui/core/Button";

export const RenderConfirm = (props) => {
  const values = {
    name: props.name,
    email: props.email,
    kaisha: props.kaisha,
    busho: props.busho,
    yakushoku: props.yakushoku,
  };

  return (
    <Dialog
      open={props.isConfirmOpen}
      onClose={props.handleClose}
      aria-labelledby="alert-dialog-title"
      aria-describedby="alert-dialog-description"
    >
      <DialogContent>
        <DialogContentText id="alert-dialog-description">
          {props.message}
        </DialogContentText>
      </DialogContent>
      <DialogActions>
        <Button
          onClick={() => props.handleSubmit(values)}
          color="primary"
          autoFocus
        >
          {props.okMessage}
        </Button>
        <Button onClick={props.handleClose}>戻る</Button>
      </DialogActions>
    </Dialog>
  );
};
```

## src/App.js

### import部分

必要なパッケージとコンポーネントとアクションをインポート

```js:src/App.js
import React, { Component } from "react";
import { connect } from "react-redux";
import { Field, reduxForm } from "redux-form";
import Button from "@material-ui/core/Button";
import Avatar from "@material-ui/core/Avatar";
import InputLabel from "@material-ui/core/InputLabel";
import FormControl from "@material-ui/core/FormControl";
import AccountCircleIcon from "@material-ui/icons/AccountCircle";
import Snackbar from "@material-ui/core/Snackbar";
import { withStyles } from "@material-ui/core/styles";
import Alert from "@material-ui/lab/Alert";
import MenuItem from "@material-ui/core/MenuItem";

import { RenderConfirm } from "./components/alert";
import { RenderField, RenderSelect } from "./components/renderfield";
import { getSelectData, changeProfile } from "./actions";
import "./style.css";
```

### material-uiのスタイルカスタマイズ部分

上部のアバターとフォームコントロールに以下スタイル適応しています。

```js:src/App.js
const styles = (theme) => ({
  avatar: {
    margin: "0 auto",
    backgroundColor: theme.palette.primary.main,
  },
  formControl: {
    margin: "12px 0",
    width: "100%",
  },
});
```

### Profileクラスコンポーネントのコンストラクタ部分

Redux使ってますが、一部state使ってます。

```js:src/App.js
class Profile extends Component {
  constructor(props) {
    super(props);
    this.handleConfirmOpen = this.handleConfirmOpen.bind(this);
    this.handleConfirmClose = this.handleConfirmClose.bind(this);
    this.handleSubmit = this.handleSubmit.bind(this);

    this.state = {
      isConfirmOpen: false,
      isAlertOpen: false,
      message: "",
      isSuccess: false,

      name: "",
      email: "",
      kaisha: "",
      busho: "",
      yakushoku: "",
    };
  }
・・・
}
```

### マウント時

セレクトボックスの選択肢データを取得しています。

```js:src/App.js
  componentDidMount() {
    this.props.getSelectData();
  }
```

### 確認ダイアログの開閉処理

Open時にstateにフォームの値を格納しています。

```js:src/App.js
  handleConfirmOpen = (values) => {
    this.setState({
      isConfirmOpen: true,
      name: values.name,
      email: values.email,
      kaisha: values.kaisha,
      busho: values.busho,
      yakushoku: values.yakushoku,
    });
  };
  handleConfirmClose = () => {
    this.setState({ isConfirmOpen: false });
  };
```

### データ送信処理

データを送信して保存する処理。
返ってきた値で成否を分けてスナックバーのメッセージと色を変更している。
今回はAPIを実装していないので、成功しか返ってきません。

```js:src/App.js
async handleSubmit(values) {
    await this.props.changeProfile(values);

    if (this.props.changes === "success") {
      this.setState({
        message: "プロフィール変更を保存しました！",
        isSuccess: true,
      });
      this.handleAlertOpen();
      this.handleConfirmClose();
    } else {
      this.setState({
        message: "プロフィール変更に失敗しました！",
        isSuccess: false,
      });
      this.handleAlertOpen();
      this.handleConfirmClose();
    }
  }
```

### スナックバー開閉処理

```js:src/App.js
  handleAlertOpen = () => {
    this.setState({ isAlertOpen: true });
  };
  handleAlertClose = () => {
    this.setState({ isAlertOpen: false });
  };
```

### render部分

```js:src/App.js
  render() {
    const { handleSubmit, pristine, submitting, invalid, classes } = this.props;
    const severity = this.state.isSuccess ? "success" : "error";
    const dataset = this.props.select;

    return (
      <div>
        <div className="form">
          <Avatar className={classes.avatar}>
            <AccountCircleIcon />
          </Avatar>
          <p className="form__title">プロフィール変更</p>

          <form onSubmit={handleSubmit(this.handleConfirmOpen)}>
            <FormControl className={classes.formControl}>
              <Field
                label="名前"
                name="name"
                type="text"
                component={RenderField}
              />
            </FormControl>
            <FormControl className={classes.formControl}>
              <Field
                label="メールアドレス"
                name="email"
                type="email"
                component={RenderField}
              />
            </FormControl>

            <FormControl className={classes.formControl}>
              <Field
                label="会社名"
                name="kaisha"
                type="text"
                component={RenderField}
              />
            </FormControl>

            <FormControl className={classes.formControl}>
              <Field
                label="部署名"
                name="busho"
                type="text"
                component={RenderField}
              />
            </FormControl>

            <FormControl variant="outlined" className={classes.formControl}>
              <InputLabel id="demo-simple-select-outlined-label">
                役職
              </InputLabel>
              <Field label="役職" name="yakushoku" component={RenderSelect}>
                {Array.isArray(dataset) &&
                  dataset.map((data, index) => (
                    <MenuItem key={index} value={data}>
                      {data}
                    </MenuItem>
                  ))}
              </Field>
            </FormControl>

            <Button
              type="submit"
              variant="contained"
              color="primary"
              disabled={pristine || submitting || invalid}
              fullWidth
            >
              保存
            </Button>

            <RenderConfirm
              isConfirmOpen={this.state.isConfirmOpen}
              handleClose={this.handleConfirmClose}
              handleSubmit={this.handleSubmit}
              name={this.state.name}
              email={this.state.email}
              kaisha={this.state.kaisha}
              busho={this.state.busho}
              yakushoku={this.state.yakushoku}
              message="プロフィール変更を保存しますか？"
              okMessage="保存"
            />
          </form>
        </div>
        <Snackbar
          open={this.state.isAlertOpen}
          autoHideDuration={3000}
          onClose={this.handleAlertClose}
          anchorOrigin={{ vertical: "top", horizontal: "center" }}
        >
          <Alert onClose={this.handleAlertClose} severity={severity}>
            {this.state.message}
          </Alert>
        </Snackbar>
      </div>
    );
  }
```

`const { handleSubmit, pristine, submitting, invalid, classes } = this.props;`の部分で必要なpropsを格納しています。

上部のアイコンもmaterial-uiのアバターとアイコンを使用しています。

```js:src/App.js
<Avatar className={classes.avatar}>
  <AccountCircleIcon />
</Avatar>
```

テキストフィールド部分は上で作成したコンポーネントを使用しています。

```js:src/App.js
<FormControl className={classes.formControl}>
  <Field
    label="名前"
    name="name"
    type="text"
    component={RenderField}
  />
</FormControl>
```

セレクトボックス部分も上で作成したコンポーネントを使用しています。
マウント時に取得した役職データをmapでループしてアイテムを追加する。

```js:src/App.js
<FormControl variant="outlined" className={classes.formControl}>
  <InputLabel id="demo-simple-select-outlined-label">
    役職
  </InputLabel>
  <Field label="役職" name="yakushoku" component={RenderSelect}>
    {Array.isArray(dataset) &&
      dataset.map((data, index) => (
        <MenuItem key={index} value={data}>
          {data}
        </MenuItem>
      ))}
  </Field>
</FormControl>
```

ボタン部分の`disabled`にはredux-formの機能を使用しています。

```js:src/App.js
<Button
  type="submit"
  variant="contained"
  color="primary"
  disabled={pristine || submitting || invalid}
  fullWidth
>
  保存
</Button>
```

スナックバー部分は`this.state.message`をpropsで受け渡してメッセージを表示しています。
また、成否は`const severity = this.state.isSuccess ? "success" : "error";`をレンダーの最初に実行して`severity `をそのまま受け渡しています。

```js:src/App.js
<Snackbar
  open={this.state.isAlertOpen}
  autoHideDuration={3000}
  onClose={this.handleAlertClose}
  anchorOrigin={{ vertical: "top", horizontal: "center" }}
>
  <Alert onClose={this.handleAlertClose} severity={severity}>
    {this.state.message}
  </Alert>
</Snackbar>
```

### validateやreduxのコネクト部分

`initialValues`でセレクトボックスの初期選択を指定しています。
コネクトするときに`enableReinitialize: true,`が必要になります。

initialValuesで値を設定しないと、セレクトボックスで見た目は選択されている（１番目）ように見えますが、そのまま送信すると値が設定されていないことになるので、設定が必要です。

```js:src/App.js
const validate = (values) => {
  const errors = {};

  if (!values.name) errors.name = "名前を入力してください。";
  if (!values.email) errors.email = "メールアドレスを入力してください。";
  if (!values.kaisha) errors.kaisha = "会社名を入力してください。";
  if (!values.busho) errors.busho = "部署名を入力してください。";
  if (!values.yakshoku) errors.yakshoku = "役職を選択してください。";

  return errors;
};

const mapStateToProps = (state) => {
  return {
    select: state.select,
    changes: state.change,
    initialValues: {
      yakushoku: state.select[0],
    },
  };
};

const mapDispatchToProps = { getSelectData, changeProfile };

export default connect(
  mapStateToProps,
  mapDispatchToProps
)(
  reduxForm({
    validate,
    enableReinitialize: true,
    form: "form",
  })(withStyles(styles, { withTheme: true })(Profile))
);
```

## まとめ

redux-formを使用しつつ、stateも一部使用しているので無理やり感が結構ありますが、とりあえずやりたいことはこれで実装できました。
material-uiを使うだけで、デザインセンスのない人（自分）でもちゃんとしたアプリっぽい見た目になってくれるので、Reactアプリには今後ほぼmaterial-uiを使ってしまいそうです。

間違っていたり、もっといい方法とかありましたら、コメントにお願いします！！