はじめに
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"
作ったもの
デモはこちら
プロフィール編集フォーム
保存確認ダイアログ
プロジェクト作成
create-react-appでReactのプロジェクトを作成して、上記のとおり必要なパッケージをインストールします。(react以外)
create-react-app app
npm install --save
src/index.js
まずReact+Reduxの基本となる部分を実装します。
redux-thunkを使用しています。
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
は保存するデータを送信する処理です。
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を実装。
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を実装。
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もここで結合。
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
テキストフィールドとセレクトボックスの部分をコンポーネント化しておく。
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で送信するデータや各種関数を受け取っておく。
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部分
必要なパッケージとコンポーネントとアクションをインポート
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のスタイルカスタマイズ部分
上部のアバターとフォームコントロールに以下スタイル適応しています。
const styles = (theme) => ({
avatar: {
margin: "0 auto",
backgroundColor: theme.palette.primary.main,
},
formControl: {
margin: "12px 0",
width: "100%",
},
});
Profileクラスコンポーネントのコンストラクタ部分
Redux使ってますが、一部state使ってます。
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: "",
};
}
・・・
}
マウント時
セレクトボックスの選択肢データを取得しています。
componentDidMount() {
this.props.getSelectData();
}
確認ダイアログの開閉処理
Open時にstateにフォームの値を格納しています。
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を実装していないので、成功しか返ってきません。
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();
}
}
スナックバー開閉処理
handleAlertOpen = () => {
this.setState({ isAlertOpen: true });
};
handleAlertClose = () => {
this.setState({ isAlertOpen: false });
};
render部分
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のアバターとアイコンを使用しています。
<Avatar className={classes.avatar}>
<AccountCircleIcon />
</Avatar>
テキストフィールド部分は上で作成したコンポーネントを使用しています。
<FormControl className={classes.formControl}>
<Field
label="名前"
name="name"
type="text"
component={RenderField}
/>
</FormControl>
セレクトボックス部分も上で作成したコンポーネントを使用しています。
マウント時に取得した役職データをmapでループしてアイテムを追加する。
<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の機能を使用しています。
<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
をそのまま受け渡しています。
<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で値を設定しないと、セレクトボックスで見た目は選択されている(1番目)ように見えますが、そのまま送信すると値が設定されていないことになるので、設定が必要です。
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を使ってしまいそうです。
間違っていたり、もっといい方法とかありましたら、コメントにお願いします!!