2019/02/06追記:
新規の場合、Redux FormよりもReact Final Formを使うことをおすすめします。(作者は同じ)
React Final Formはreduxに依存しないのと作者の開発がReact Final Formをメインにしているからです。
Reactでモダンなフォームを作る
ReactでのメジャーなUIライブラリであるMaterial-UIとFormのステート管理ライブラリであるReduxFormを組み合わせてモダンなフォームを作ります。
ReduxForm公式のMUIサンプルが古すぎた(MUI v0から更新されていない・・・)のと個人的に一通り整理したくなったので、MUI(v3)と組み合わせたサンプルを作りました。
→追記:しれっとReduxForm v8.1.0のサンプルで追従していました
ReduxFormの使い方を熟知するとフォームデータ用のstateが不要になります。
Reactがはじめての人はこちらにまとめたので参考にしてください。
ReactJSで作る今時のSPA入門(基本編)
フォーム
今回のサンプル: https://github.com/teradonburi/muiReduxForm
yarnインストール前提で次のコマンドで起動できます。
$ yarn
$ yarn dev
一通りフォームで必要なコンポーネントを作成します。
- テキスト(1行)
- パスワード(1行)
- テキストエリア(複数行)
- セレクト
- ラジオボタン
- チェックボックス
- スイッチ
- ファイルアップロード(画像)
- 動的な項目追加できるグループ
フォームの実装部分です。(App.js)
import React from 'react'
import { connect } from 'react-redux'
import Button from '@material-ui/core/Button'
import TextField from '@material-ui/core/TextField'
import FormControl from '@material-ui/core/FormControl'
import FormHelperText from '@material-ui/core/FormHelperText'
import MenuItem from '@material-ui/core/MenuItem'
import Radio from '@material-ui/core/Radio'
import RadioGroup from '@material-ui/core/RadioGroup'
import FormControlLabel from '@material-ui/core/FormControlLabel'
import FormLabel from '@material-ui/core/FormLabel'
import Checkbox from '@material-ui/core/Checkbox'
import FormGroup from '@material-ui/core/FormGroup'
import Switch from '@material-ui/core/Switch'
import { reduxForm, Field, FieldArray } from 'redux-form'
import { withStyles } from '@material-ui/core'
import { create } from './modules/user'
// TextField
const renderTextField = ({
input,
label,
meta: { touched, error },
type='text',
required = false,
rootClass = '',
}) => (
<TextField
required={required}
classes={{root: rootClass}}
error={!!(touched && error)}
label={label}
type={type}
variant='outlined'
helperText={touched && error}
{...input}
/>
)
// TextArea
const renderTextArea = ({
input,
label,
meta: { touched, error },
rows = 4,
required = false,
rootClass = '',
}) => (
<TextField
required={required}
classes={{root: rootClass}}
multiline
rows={rows}
error={!!(touched && error)}
label={label}
variant='outlined'
helperText={touched && error}
{...input}
/>
)
// Select
const renderSelect = ({
input: { value, onChange },
label,
children,
meta: { touched, error },
onFieldChange,
required = false,
rootClass = '',
}) => (
<TextField
required={required}
classes={{root: rootClass}}
select
label={label}
variant='outlined'
value={value}
onChange={e => {
onChange(e.target.value)
onFieldChange && onFieldChange(e.target.value)
}}
helperText={touched && error}
>
{children}
</TextField>
)
// RadioButton
const renderRadio = ({
input: { value, onChange },
label,
children,
meta: { touched, error },
onFieldChange,
row = true,
required = false,
rootClass = '',
}) => (
<FormControl classes={{root: rootClass}} required={required} component='fieldset' error={!!(touched && error)}>
<FormLabel component='legend'>{label}</FormLabel>
<RadioGroup
row={row}
value={value}
onChange={(e) => {
onChange(e.target.value)
onFieldChange && onFieldChange(e.target.value)
}}
>
{children}
</RadioGroup>
{touched && error && <FormHelperText>{error}</FormHelperText>}
</FormControl>
)
// CheckBox
const renderCheckBox = ({
input: { value, onChange },
label,
children,
meta: { touched, error },
onFieldChange,
row = true,
required = false,
rootClass = '',
}) => (
<FormControl classes={{root: rootClass}} required={required} component='fieldset' error={!!(touched && error)}>
<FormLabel component='legend'>{label}</FormLabel>
<FormGroup
row={row}
value={value}
onChange={(e) => {
onChange(e.target.value)
onFieldChange && onFieldChange(e.target.value)
}}
>
{children}
</FormGroup>
{touched && error && <FormHelperText>{error}</FormHelperText>}
</FormControl>
)
// Switch
const renderSwitch = ({
input: { value, onChange },
label,
onFieldChange,
rootClass = '',
}) => (
<FormControlLabel
classes={{root: rootClass}}
control={
<Switch
checked={value}
onChange={(e, bool) => {
onChange(bool)
onFieldChange && onFieldChange(bool)
}}
/>
}
label={label}
/>
)
// File
const renderFile = withStyles(() => ({
input: {
display: 'none',
},
button: {
marginTop: 10,
},
}))(
({
input: { value, name, onChange },
label,
meta: { touched, error },
classes,
onFieldChange,
buttonLabel,
accept = '*',
required = false,
rootClass = '',
}) => (
<FormControl classes={{root: rootClass}} required={required} component='fieldset' error={!!(touched && error)}>
<FormLabel component='legend'>{label}</FormLabel>
<input
accept={accept}
className={classes.input}
id={name}
type='file'
onChange={e => {
e.preventDefault()
onChange(e.target.files[0])
onFieldChange && onFieldChange(e.target.files[0])
}}
onBlur={() => {}}
/>
<label htmlFor={name}>
<Button classes={{root: classes.button}} variant='outlined' component='span'>
{buttonLabel || 'ファイルを選択'}
</Button>
</label>
<label>{value && value.name}</label>
{touched && error && <FormHelperText>{error}</FormHelperText>}
</FormControl>
)
)
// Dynamic Items
const renderMembers = withStyles(theme => ({
input: {
display: 'flex',
},
space: {
marginLeft: 10,
},
member: {
marginTop: 10,
padding: 10,
border: `1px solid ${theme.palette.grey[400]}`,
borderRadius: 10,
},
header: {
marginTop: 0,
marginBottom: 10,
},
addButton: {
marginTop: 10,
},
deleteButton: {
height: 30,
},
}))(
({
label,
fields,
meta: { error, submitFailed },
classes,
required = false,
rootClass = '',
}) => (
<FormControl classes={{root: rootClass}} required={required} component='fieldset' error={!!(submitFailed && error)}>
<FormLabel component='legend'>{label}</FormLabel>
{fields.map((member, idx) => (
<div key={idx} className={classes.member}>
<h6 className={classes.header}>メンバー{idx + 1}</h6>
<div className={classes.input}>
<Field
name={`${member}.lastName`}
component={renderTextField}
label='姓'
rootClass={classes.space}
/>
<Field
name={`${member}.firstName`}
component={renderTextField}
label='名'
rootClass={classes.space}
/>
<Button classes={{root: [classes.deleteButton, classes.space].join(' ')}} size='small' variant='contained' color='secondary' onClick={() => fields.remove(idx)}>削除</Button>
</div>
</div>
))}
<div>
<Button classes={{root: classes.addButton}} size='medium' variant='contained' color='primary' onClick={() => fields.push({})}>追加</Button>
{submitFailed && error && <FormHelperText>{error}</FormHelperText>}
</div>
</FormControl>
)
)
@reduxForm({
form: 'form',
validate: values => {
const errors = {}
if (!values.text) {
errors.text = '必須項目です'
}
if (!values.password) {
errors.password = '必須項目です'
}
if (!values.textarea) {
errors.textarea = '必須項目です'
}
if (!values.select) {
errors.select = '必須項目です'
}
if (!values.radio) {
errors.radio = '必須項目です'
}
if (!values.checkbox) {
errors.checkbox = '必須項目です'
}
if (!values.image) {
errors.image = '必須項目です'
}
if (!values.members || !values.members.length) {
errors.members = { _error: '1人以上メンバーの追加が必要です' }
} else {
const membersArrayErrors = []
values.members.forEach((member, memberIndex) => {
const memberErrors = {}
if (!member || !member.firstName) {
memberErrors.firstName = '必須項目です'
membersArrayErrors[memberIndex] = memberErrors
}
if (!member || !member.lastName) {
memberErrors.lastName = '必須項目です'
membersArrayErrors[memberIndex] = memberErrors
}
})
if (membersArrayErrors.length) {
errors.members = membersArrayErrors
}
}
return errors
},
})
@connect(
state => ({
send: state.user.user,
}),
{ create }
)
@withStyles(() => ({
formControl: {
marginTop: 10,
marginBottom: 10,
},
}))
export default class MainPage extends React.Component {
constructor(props) {
super(props)
this.props.initialize({text: 'てきすと'})
}
submit = (values) => {
const params = new FormData()
params.append('text', values.text)
params.append('password', values.password)
params.append('textarea', values.textarea)
params.append('select', values.select)
params.append('radio', values.radio)
params.append('checkbox', values.checkbox)
params.append('switch', !!values.switch)
params.append('image', values.image)
params.append('members', JSON.stringify(values.members))
this.props.create(params)
}
render () {
const { classes, handleSubmit, send } = this.props
return (
<form onSubmit={handleSubmit(this.submit)} encType='multipart/form-data'>
<div style={{width: 400, display: 'flex', flexDirection: 'column'}} >
<Field name='text' label='テキストフィールド' component={renderTextField} rootClass={classes.formControl} required />
<Field name='password' type='password' label='パスワード' component={renderTextField} rootClass={classes.formControl} required />
<Field name='textarea' label='テキストエリア' component={renderTextArea} rootClass={classes.formControl} required />
<Field name='select' label='セレクト' component={renderSelect} rootClass={classes.formControl} required >
<MenuItem value=''>未選択</MenuItem>
<MenuItem value={10}>10円</MenuItem>
<MenuItem value={20}>20円</MenuItem>
<MenuItem value={30}>30円</MenuItem>
</Field>
<Field name='radio' label='ラジオボタン' component={renderRadio} rootClass={classes.formControl} required >
<FormControlLabel value='female' control={<Radio />} label='女性' />
<FormControlLabel value='male' control={<Radio />} label='男性' />
<FormControlLabel value='other' control={<Radio />} label='その他' />
</Field>
<Field name='checkbox' label='チェックボックス' component={renderCheckBox} rootClass={classes.formControl} required >
<FormControlLabel value='check1' control={<Checkbox />} label='オプション1'/>
<FormControlLabel value='check2' control={<Checkbox />} label='オプション2'/>
<FormControlLabel value='check3' control={<Checkbox />} label='オプション3'/>
</Field>
<Field name='switch' label='スイッチ' component={renderSwitch} />
<Field name='image' label='画像' accept='image/*' component={renderFile} rootClass={classes.formControl} required />
<FieldArray name='members' label='メンバー' component={renderMembers} rootClass={classes.formControl} required />
<Button type='submit' size='medium' variant='contained' color='primary' >送信</Button>
</div>
<div>
{send && JSON.stringify(send)}
</div>
</form>
)
}
}
renderMember以外のrender*は使いまわしできるように作成しました。
フォームのパラメータのチェックはvalidateで行います。
initializeでフォームの初期化処理を行います。
submitとvalidateで渡ってくるvaluesには、Fieldのname属性で指定した入力データがオブジェクト形式で入ってきます。
今回、画像送信するため、submitはFormData(multipart/form-data形式)での送信を行います。
@reduxForm({
form: 'form',
validate: values => {
...
})
})
export default class MainPage extends React.Component {
submit = (values) => {
const params = new FormData()
params.append('text', values.text)
params.append('password', values.password)
params.append('textarea', values.textarea)
params.append('select', values.select)
params.append('radio', values.radio)
params.append('checkbox', values.checkbox)
params.append('switch', !!values.switch)
params.append('image', values.image)
params.append('members', JSON.stringify(values.members))
this.props.create(params)
}
render () {
const { classes, handleSubmit, send } = this.props
return (
<form onSubmit={handleSubmit(this.submit)} encType='multipart/form-data'>
<div style={{width: 400, display: 'flex', flexDirection: 'column'}} >
<Field name='text' label='テキストフィールド' component={renderTextField} rootClass={classes.formControl} required />
...
</form>
)
}
}
テキスト入力
MUIのTextField Componentでテキスト入力できます。input, metaに関してはField Component経由で渡ってくるデータです。
touchedは一度でもフォーカスが当たった場合にtrueになります。
error時はerror属性がtrueになり、赤字になり、helperTextにてerror内容が表示されます。
必須欄の場合はrequiredをtrueにします。
rootClassは外部からレイアウト調整するために渡しているclass名です。
// TextField
const renderTextField = ({
input,
label,
meta: { touched, error },
type='text',
required = false,
rootClass = '',
}) => (
<TextField
required={required}
classes={{root: rootClass}}
error={!!(touched && error)}
label={label}
type={type}
variant='outlined'
helperText={touched && error}
{...input}
/>
)
Field Componentのcomponent属性にてwrapします。
<Field name='text' label='テキストフィールド' component={renderTextField} rootClass={classes.formControl} required />
パスワード入力
パスワードの場合はrenderTextFieldにtype='password'を渡せばパスワード入力欄になります。
テキストエリア
renderTextFieldにrows(行数)とmultiline属性がついただけです。
// TextArea
const renderTextArea = ({
input,
label,
meta: { touched, error },
rows = 4,
required = false,
rootClass = '',
}) => (
<TextField
required={required}
classes={{root: rootClass}}
multiline
rows={rows}
error={!!(touched && error)}
label={label}
variant='outlined'
helperText={touched && error}
{...input}
/>
)
セレクト
選択欄を作ります。TextFieldにselect属性を指定します。
また、選択項目を受け取った場合にonChangeでReduxFormの値を更新します。
onFieldChangeはカスタムのハンドリングをしたい場合に渡しています(今回は使っていません)
// Select
const renderSelect = ({
input: { value, onChange },
label,
children,
meta: { touched, error },
onFieldChange,
required = false,
rootClass = '',
}) => (
<TextField
required={required}
classes={{root: rootClass}}
select
label={label}
variant='outlined'
value={value}
onChange={e => {
onChange(e.target.value)
onFieldChange && onFieldChange(e.target.value)
}}
helperText={touched && error}
>
{children}
</TextField>
)
選択項目はMenuItem Componentで作成します。
<Field name='select' label='セレクト' component={renderSelect} rootClass={classes.formControl} required >
<MenuItem value=''>未選択</MenuItem>
<MenuItem value={10}>10円</MenuItem>
<MenuItem value={20}>20円</MenuItem>
<MenuItem value={30}>30円</MenuItem>
</Field>
ラジオボタン
ラジオボタンはFormControl、FormLabel、RadioGroup、FormHelperText等のSelection Componentを使います。
// RadioButton
const renderRadio = ({
input: { value, onChange },
label,
children,
meta: { touched, error },
onFieldChange,
row = true,
required = false,
rootClass = '',
}) => (
<FormControl classes={{root: rootClass}} required={required} component='fieldset' error={!!(touched && error)}>
<FormLabel component='legend'>{label}</FormLabel>
<RadioGroup
row={row}
value={value}
onChange={(e) => {
onChange(e.target.value)
onFieldChange && onFieldChange(e.target.value)
}}
>
{children}
</RadioGroup>
{touched && error && <FormHelperText>{error}</FormHelperText>}
</FormControl>
)
選択項目はFormControlLabelを使います。
<Field name='radio' label='ラジオボタン' component={renderRadio} rootClass={classes.formControl} required >
<FormControlLabel value='female' control={<Radio />} label='女性' />
<FormControlLabel value='male' control={<Radio />} label='男性' />
<FormControlLabel value='other' control={<Radio />} label='その他' />
</Field>
チェックボックス
チェックボックスはFormControl、FormLabel、FormGroup、FormHelperText等のSelection Componentを使います。
// CheckBox
const renderCheckBox = ({
input: { value, onChange },
label,
children,
meta: { touched, error },
onFieldChange,
row = true,
required = false,
rootClass = '',
}) => (
<FormControl classes={{root: rootClass}} required={required} component='fieldset' error={!!(touched && error)}>
<FormLabel component='legend'>{label}</FormLabel>
<FormGroup
row={row}
value={value}
onChange={(e) => {
onChange(e.target.value)
onFieldChange && onFieldChange(e.target.value)
}}
>
{children}
</FormGroup>
{touched && error && <FormHelperText>{error}</FormHelperText>}
</FormControl>
)
選択項目はFormControlLabelを使います。
<Field name='checkbox' label='チェックボックス' component={renderCheckBox} rootClass={classes.formControl} required >
<FormControlLabel value='check1' control={<Checkbox />} label='オプション1'/>
<FormControlLabel value='check2' control={<Checkbox />} label='オプション2'/>
<FormControlLabel value='check3' control={<Checkbox />} label='オプション3'/>
</Field>
スイッチ
スイッチはFormControlLabel、Switch等のSelection Componentを使います。
// Switch
const renderSwitch = ({
input: { value, onChange },
label,
onFieldChange,
rootClass = '',
}) => (
<FormControlLabel
classes={{root: rootClass}}
control={
<Switch
checked={value}
onChange={(e, bool) => {
onChange(bool)
onFieldChange && onFieldChange(bool)
}}
/>
}
label={label}
/>
)
ファイル(画像)
Buttonを押すとファイル選択させます。
実際にはlabelのhtmlFor属性でinputタグ(非表示)をクリックさせ、ファイルを選択させます。
onChangeのイベントハンドラでe.target.files[0]からFileオブジェクトを取得します。
// File
const renderFile = withStyles(() => ({
input: {
display: 'none',
},
button: {
marginTop: 10,
},
}))(
({
input: { value, name, onChange },
label,
meta: { touched, error },
classes,
onFieldChange,
buttonLabel,
accept = '*',
required = false,
rootClass = '',
}) => (
<FormControl classes={{root: rootClass}} required={required} component='fieldset' error={!!(touched && error)}>
<FormLabel component='legend'>{label}</FormLabel>
<input
accept={accept}
className={classes.input}
id={name}
type='file'
onChange={e => {
e.preventDefault()
onChange(e.target.files[0])
onFieldChange && onFieldChange(e.target.files[0])
}}
onBlur={() => {}}
/>
<label htmlFor={name}>
<Button classes={{root: classes.button}} variant='outlined' component='span'>
{buttonLabel || 'ファイルを選択'}
</Button>
</label>
<label>{value && value.name}</label>
{touched && error && <FormHelperText>{error}</FormHelperText>}
</FormControl>
)
)
acceptに正しいMIMEを指定します。(今回は画像なのでimage/*)
ファイルの拡張子が偽装されていないかのチェックは今回省略しています。
<Field name='image' label='画像' accept='image/*' component={renderFile} rootClass={classes.formControl} required />
動的項目
動的に項目を増やしたい場合はReduxFormのFieldArray Componentを使います。
fieldsに項目の数分のComponentの配列が渡ってきます。
項目を追加したい場合はfields.push({})
を行います。
項目を削除したい場合はfields.remove(idx)
で指定の番号の項目を削除します。
// Dynamic Items
const renderMembers = withStyles(theme => ({
input: {
display: 'flex',
},
space: {
marginLeft: 10,
},
member: {
marginTop: 10,
padding: 10,
border: `1px solid ${theme.palette.grey[400]}`,
borderRadius: 10,
},
header: {
marginTop: 0,
marginBottom: 10,
},
addButton: {
marginTop: 10,
},
deleteButton: {
height: 30,
},
}))(
({
label,
fields,
meta: { error, submitFailed },
classes,
required = false,
rootClass = '',
}) => (
<FormControl classes={{root: rootClass}} required={required} component='fieldset' error={!!(submitFailed && error)}>
<FormLabel component='legend'>{label}</FormLabel>
{fields.map((member, idx) => (
<div key={idx} className={classes.member}>
<h6 className={classes.header}>メンバー{idx + 1}</h6>
<div className={classes.input}>
<Field
name={`${member}.lastName`}
component={renderTextField}
label='姓'
rootClass={classes.space}
/>
<Field
name={`${member}.firstName`}
component={renderTextField}
label='名'
rootClass={classes.space}
/>
<Button classes={{root: [classes.deleteButton, classes.space].join(' ')}} size='small' variant='contained' color='secondary' onClick={() => fields.remove(idx)}>削除</Button>
</div>
</div>
))}
<div>
<Button classes={{root: classes.addButton}} size='medium' variant='contained' color='primary' onClick={() => fields.push({})}>追加</Button>
{submitFailed && error && <FormHelperText>{error}</FormHelperText>}
</div>
</FormControl>
)
)
Field内部で使っているComponentはFieldArray Componentでwrapします。
<FieldArray name='members' label='メンバー' component={renderMembers} rootClass={classes.formControl} required />
validateチェックも配列形式でvaluesが渡ってくるため、
配列内の各項目に対してチェックが必要です。
validate: values => {
const errors = {}
if (!values.members || !values.members.length) {
errors.members = { _error: '1人以上メンバーの追加が必要です' }
} else {
const membersArrayErrors = []
values.members.forEach((member, memberIndex) => {
const memberErrors = {}
if (!member || !member.firstName) {
memberErrors.firstName = '必須項目です'
membersArrayErrors[memberIndex] = memberErrors
}
if (!member || !member.lastName) {
memberErrors.lastName = '必須項目です'
membersArrayErrors[memberIndex] = memberErrors
}
})
if (membersArrayErrors.length) {
errors.members = membersArrayErrors
}
}
}
送信
submitボタンが押されるとReduxFormのhandleSubmitが実行され、submitメソッドが呼ばれます。
ファイル送信するため、今回はFormDataでフォームのデータを格納します。
submit = (values) => {
const params = new FormData()
params.append('text', values.text)
params.append('password', values.password)
params.append('textarea', values.textarea)
params.append('select', values.select)
params.append('radio', values.radio)
params.append('checkbox', values.checkbox)
params.append('switch', !!values.switch)
params.append('image', values.image)
params.append('members', JSON.stringify(values.members))
this.props.create(params)
}
render () {
const { classes, handleSubmit, send } = this.props
return (
<form onSubmit={handleSubmit(this.submit)} encType='multipart/form-data'>
...
<Button type='submit' size='medium' variant='contained' color='primary' >送信</Button>
</form>
)
}
サーバ側
正しく送信されるか確認するサーバ側はnodejs+expressで作っています。(楽なので)
multipart-formデータのファイル受信をするためにmulterを導入しています。
今回はuploadsフォルダに画像をアップロードするようにしています。
const express = require('express')
const bodyParser = require('body-parser')
const app = express()
const multer = require('multer')
const upload = multer({ dest: 'uploads/' })
process.on('uncaughtException', (err) => console.error(err))
process.on('unhandledRejection', (err) => console.error(err))
app.use(express.static('.'))
app.use(bodyParser.urlencoded({extended: true}))
app.use(bodyParser.json())
app.post('/api/user', upload.single('image'), (req, res) => {
console.log(req.body)
console.log(req.file)
res.json({image: req.file, ...req.body, members: JSON.parse(req.body.members)})
})
app.listen(9090, () => {
console.log('Access to http://localhost:9090')
})
次のようなログが出力され、uploadsフォルダに画像がアップロードされます。
{ text: 'てきすと',
password: 'password',
textarea: 'えりあ',
select: '20',
radio: 'male',
checkbox: 'check1',
switch: 'true',
members: '[{"lastName":"ほげ","firstName":"ふが"},{"lastName":"たろう","firstName":"はなこ"}]' }
{ fieldname: 'image',
originalname: 'mitumo.png',
encoding: '7bit',
mimetype: 'image/png',
destination: 'uploads/',
filename: '223805f032c88a5c41335a7901e5e945',
path: 'uploads/223805f032c88a5c41335a7901e5e945',
size: 62471 }