TODO! This example needs to be updated for Kea 1.0!
Everything below should still work, but might no longer be considered best practice
Example - Forms
Before Kea I used to dread forms in React. They would always require a lot of work to set up properly.
I could either use setState
for the simplest one-component forms... or a bulky library like redux-form for a connected Redux-based form. Neither of those is a great option... and writing a pure-redux-form requires way too much boilerplate.
With Kea, forms are finally easy! Even the code to setup a form from scratch is quick to write, as you shall soon see.
What shall we do?
In this chapter we will first build a simple form that looks like this:
Go ahead, play with it!
... and then abstract the remaining boilerplate into a form builder.
0. Install kea-saga
We'll be using sagas in this example. To add support for them, install the kea-saga
and redux-saga
packages.
# if using yarn
yarn add kea-saga redux-saga
# if using npm
npm install kea-saga redux-saga --save
Then import sagaPlugin
from kea-saga
and add it to your resetContext()
:
import sagaPlugin from 'kea-saga'
resetContext({
plugins: [
sagaPlugin
]
})
1. Defining the features we need
If you played with the demo above, you'll see what we need to build the following features:
- Default values
- Custom validation rules
- Show errors only after we have pressed "submit"
- Disable the submit button when submitting
- Async processing of the request
Let's build it piece by piece, starting with the data model.
2. Actions and reducers
So what do we need to keep track of in order to build this form?
At the very minimum, we'll need the following reducers:
- An object
values
, which contains the form data (name
,email
andmessage
) - A boolean
isSubmitting
, which knows if we're actively submitting the form or not - A boolean
showErrors
to know if we will show errors or not
These three reducers are enough to give us everything, except for validation rules and errors. We'll skip those for now.
What about the actions we can perform on the form? The minimum set is as follows:
setValue
to update the value of one field (orsetValues
to update many simultaneously)submit
, to try to submit the formsubmitSuccess
, if the form was successfully submittedsubmitFailure
, if there was an error, e.g. a validation mismatch
Putting them together and adding defaults
and propTypes
gives us the following code:
// form.js
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import { kea } from 'kea'
const defaults = {
name: 'John Doe',
email: '',
message: ''
}
const propTypes = {
name: PropTypes.string,
email: PropTypes.string,
message: PropTypes.string
}
export default kea({
actions: () => ({
setValue: (key, value) => ({ key, value }),
setValues: (values) => ({ values }),
submit: true,
submitSuccess: true,
submitFailure: true
}),
reducers: ({ actions }) => ({
values: [defaults, PropTypes.shape(propTypes), {
[actions.setValue]: (state, payload) => {
return Object.assign({}, state, { [payload.key]: payload.value })
},
[actions.setValues]: (state, payload) => {
return Object.assign({}, state, payload.values)
},
[actions.submitSuccess]: () => defaults
}],
isSubmitting: [false, PropTypes.bool, {
[actions.submit]: () => true,
[actions.submitSuccess]: () => false,
[actions.submitFailure]: () => false
}],
showErrors: [false, PropTypes.bool, {
[actions.submit]: () => true,
[actions.submitSuccess]: () => false
}]
}),
// ...
})
Seems clear enough? :)
3. The component
We could continue extending the logic by adding error handling, validations and actual submission logic, but since it's nice to see something tangible, let's first build the component itself!
A very crude version will look something like this:
// index.js
import React, { Component } from 'react'
import { connect } from 'kea'
import form from './form'
@connect({
actions: [
form, [
'setValue',
'submit'
]
],
values: [
form, [
'values',
'isSubmitting'
]
]
})
export default class FormComponent extends Component {
render () {
const { isSubmitting, values } = this.props
const { submit, setValue } = this.actions
const { name, email, message } = values
return (
<div>
<div className='form-field'>
<label>Name</label>
<input type='text' value={name} onChange={e => setValue('name', e.target.value)} />
</div>
<div className='form-field'>
<label>E-mail</label>
<input type='text' value={email} onChange={e => setValue('email', e.target.value)} />
</div>
<div className='form-field'>
<label className='block'>Message</label>
<textarea value={message} onChange={e => setValue('message', e.target.value)} />
</div>
<button disabled={isSubmitting} onClick={submit}>
{isSubmitting ? 'Submitting...' : 'Submit!'}
</button>
</div>
)
}
}
This code works! In fact, try it below:
The only problem: once you hit "submit", it will forever be stuck in the isSubmitting
state.
We need to add some logic to make it actually do something.
4. Submitting the form
We will use the takeLatest
helper to listen to the submit action and respond with either a submitSuccess
or submitFailure
action:
// form.js
import { put, delay } from 'redux-saga/effects'
export default kea({
// actions, reducers, ...
takeLatest: ({ actions, workers }) => ({
[actions.submit]: function * () {
const { submitSuccess, submitFailure } = this.actions
// get the form data...
const values = yield this.get('values')
console.log('Submitting form with values:', values)
// simulate a 1sec async request.
yield delay(1000)
if (true) { // if the request was successful
window.alert('Success')
yield put(submitSuccess())
} else {
window.alert('Error')
yield put(submitFailure())
}
}
})
})
Adding this code results in the following form:
Go ahead, write some data and try submitting it!
If you replace the yield delay(1000)
part with an actual API call, this will be a fully functional form.
Only one thing left to do...
5. Errors and validation
We want to prevent an empty form from being submitted!
The easiest solution is to create a selector errors
that depends on values
. This selector checks the content of each field and returns an object describing which fields have errors.
We'll also create another selector hasErrors
, which gives a simple yes/no answer to the question "does this form have errors?".
Finally, we'll check the value of hasErrors
in the submit
worker, and dispatch a submitFailure
action in case the form doesn't pass the validation.
Something like this:
// form.js
// ...
const isEmailValid = (email) => /[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/.test(email)
const missingText = 'This field is required'
const invalidEmail = 'Invalid e-mail'
export default kea({
// actions, reducers, takeLatest, ...
selectors: ({ selectors }) => ({
errors: [
() => [selectors.values],
(values) => ({
name: !values.name ? missingText : null,
email: !values.email ? missingText : (!isEmailValid(values.email) ? invalidEmail : null),
message: !values.message ? missingText : null
}),
PropTypes.object
],
hasErrors: [
() => [selectors.errors],
(errors) => Object.values(errors).filter(k => k).length > 0,
PropTypes.bool
]
}),
takeLatest: ({ actions, workers }) => ({
[actions.submit]: function * () {
const { submitSuccess, submitFailure } = this.actions
const hasErrors = yield this.get('hasErrors')
if (hasErrors) {
yield put(submitFailure())
return
}
// ... the rest of the submit worker
}
})
})
In order to display the errors, we will also update our component as follows:
// index.js
export default class Form extends Component {
render () {
const { isSubmitting, errors, values } = this.props
const { submit, setValue } = this.actions
const { name, email, message } = values
return (
<div>
<div className='form-field'>
<label>Name</label>
<input type='text' value={name} onChange={e => setValue('name', e.target.value)} />
{errors.name ? <div className='form-error'>{errors.name}</div> : null}
</div>
<div className='form-field'>
<label>E-mail</label>
<input type='text' value={email} onChange={e => setValue('email', e.target.value)} />
{errors.email ? <div className='form-error'>{errors.email}</div> : null}
</div>
<div className='form-field'>
<label className='block'>Message</label>
<textarea value={message} onChange={e => setValue('message', e.target.value)} />
{errors.message ? <div className='form-error'>{errors.message}</div> : null}
</div>
<button disabled={isSubmitting} onClick={submit}>
{isSubmitting ? 'Submitting...' : 'Submit!'}
</button>
</div>
)
}
}
Plugging in the changes results in the following form:
Almost perfect! The only thing: we don't want to show the red errors before the user submits the form.
Remember the showErrors
reducer from before? Now is its time to shine!
We have two choices with it. We can either use it in our render
function like so:
export default class Form extends Component {
render () {
const { isSubmitting, errors, values, showErrors } = this.props
return (
<div>
<div className='form-field'>
...
{showErrors && errors.name
? <div className='form-error'>{errors.name}</div>
: null}
</div>
...
</div>
)
}
}
... or we can simply return an empty hash for the errors
selector until showErrors
becomes true.
I prefer the second approach as it moves the form logic away from the render
function.
In order to do this, we'll rename the previous selector errors
into allErrors
and make an new selector errors
, that depends on both allErrors
and showErrors
. We'll also make hasErrors
depend on the renamed allErrors
:
// form.js
// ...
export default kea({
// actions, reducers, takeLatest, ...
selectors: ({ selectors }) => ({
allErrors: [
() => [selectors.values],
(values) => ({
name: !values.name ? missingText : null,
email: !values.email ? missingText : (!isEmailValid(values.email) ? invalidEmail : null),
message: !values.message ? missingText : null
}),
PropTypes.object
],
hasErrors: [
() => [selectors.allErrors],
(allErrors) => Object.values(allErrors).filter(k => k).length > 0,
PropTypes.bool
],
errors: [
() => [selectors.allErrors, selectors.showErrors],
(errors, showErrors) => showErrors ? errors : {},
PropTypes.object
]
})
})
And that's it! With a few actons, reducers and selectors, totalling about 75 lines of code, you have a fully functional and extremely extendable form library at your disposal!
This is the final result:
Note that it shares data with the form on top of the page.
6. Abstracting createForm
As you saw, it's really easy to create forms with Kea. And you're no longer dependant on heavy (~50KB) form libraries!
That said, what if you need a second form on your page? Should you copy paste all this code around?
No!
There's not a lot of boilerplate with our form solution, but even what is there can be eliminated.
Let's build a form builder!
The principle here is simple. We'll create a function, createForm
, that takes as an input all the form-specific data and returns a kea({})
logic store. Something like this:
// create-form.js
import PropTypes from 'prop-types'
import { kea } from 'kea'
export default function createForm (options) {
// parse and clean up `options`
return kea({
// actions, reducers, etc that are built from `options`
})
}
So what is all of the form-specific data? What kind of an API do we want the createForm
function to have?
Well, here's one option:
// form.js
import PropTypes from 'prop-types'
import { delay } from 'redux-saga/effects'
import createForm from './create-form'
const isEmailValid = (email) => /[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/.test(email)
const missingText = 'This field is required'
export default createForm({
propTypes: {
name: PropTypes.string,
email: PropTypes.string,
message: PropTypes.string
},
defaults: {
name: 'John Doe',
email: '',
message: ''
},
validate: (values) => ({
name: !values.name ? missingText : null,
email: !values.email ? missingText : (!isEmailValid(values.email) ? 'Invalid e-mail' : null),
message: !values.message ? missingText : null
}),
submit: function * () {
// simulate a 1sec submission
yield delay(1000)
// either return the response:
// -> return { results: [] }
// or throw an exception:
// -> throw 'Everything is broken!'
},
success: function * (response) {
window.alert('submit successful!', response)
},
failure: function * (error) {
window.alert('submit error!', error.message)
}
})
That's about as lean as it gets!
And what about this createForm
?
Turns out it requires very minimal changes from the code above. Here it is in all its glory. See if you can spot what changed:
// create-form.js
import PropTypes from 'prop-types'
import { kea } from 'kea'
import { put, call } from 'redux-saga/effects'
const noop = () => ({})
export default function createForm (options) {
const propType = options.propTypes ? PropTypes.shape(options.propTypes) : PropTypes.object
const defaults = options.defaults || {}
const validate = options.validate || noop
const submit = options.submit || noop
const success = options.success || noop
const failure = options.failure || noop
return kea({
actions: () => ({
setValue: (key, value) => ({ key, value }),
setValues: (values) => ({ values }),
submit: true,
submitSuccess: (response) => ({ response }),
submitFailure: (error) => ({ error })
}),
reducers: ({ actions }) => ({
values: [defaults, propType, {
[actions.setValue]: (state, payload) => {
return Object.assign({}, state, { [payload.key]: payload.value })
},
[actions.setValues]: (state, payload) => {
return Object.assign({}, state, payload.values)
},
[actions.submitSuccess]: () => defaults
}],
isSubmitting: [false, PropTypes.bool, {
[actions.submit]: () => true,
[actions.submitSuccess]: () => false,
[actions.submitFailure]: () => false
}],
showErrors: [false, PropTypes.bool, {
[actions.submit]: () => true,
[actions.submitSuccess]: () => false
}]
}),
selectors: ({ selectors }) => ({
allErrors: [
() => [selectors.values],
validate,
PropTypes.object
],
hasErrors: [
() => [selectors.allErrors],
(allErrors) => Object.values(allErrors).filter(k => k).length > 0,
PropTypes.bool
],
errors: [
() => [selectors.allErrors, selectors.showErrors],
(errors, showErrors) => showErrors ? errors : {},
PropTypes.object
]
}),
takeLatest: ({ actions, workers }) => ({
[actions.submit]: function * () {
const { submitSuccess, submitFailure } = this.actions
const hasErrors = yield this.get('hasErrors')
if (hasErrors) {
yield put(submitFailure())
return
}
try {
const response = yield call(submit.bind(this))
yield call(success.bind(this), response)
yield put(submitSuccess(response))
} catch (error) {
yield call(failure.bind(this), error)
yield put(submitFailure(error))
}
}
})
})
}
Since the result of calling createForm
is just a regular kea logic store, you may connect to it in any way you please. In fact, the code for the component itself is identical to what you saw above.
Here it is again for completion:
// index.js
import React, { Component } from 'react'
import { connect } from 'kea'
import form from './form'
@connect({
actions: [
form, [
'setValue',
'submit'
]
],
values: [
form, [
'values',
'isSubmitting',
'errors'
]
]
})
export default class CreatedForm extends Component {
render () {
const { isSubmitting, errors, values } = this.props
const { submit, setValue } = this.actions
const { name, email, message } = values
return (
<div>
<div className='form-field'>
<label>Name</label>
<input type='text' value={name} onChange={e => setValue('name', e.target.value)} />
{errors.name ? <div className='form-error'>{errors.name}</div> : null}
</div>
<div className='form-field'>
<label>E-mail</label>
<input type='text' value={email} onChange={e => setValue('email', e.target.value)} />
{errors.email ? <div className='form-error'>{errors.email}</div> : null}
</div>
<div className='form-field'>
<label className='block'>Message</label>
<textarea value={message} onChange={e => setValue('message', e.target.value)} />
{errors.message ? <div className='form-error'>{errors.message}</div> : null}
</div>
<button disabled={isSubmitting} onClick={submit}>
{isSubmitting ? 'Submitting...' : 'Submit!'}
</button>
</div>
)
}
}
And this is the created form in action:
Now, there are surely additional things that can be done. For example:
- You may create an abstract
Field
component that removes even more boilerplate. - You may add extra code for async validation. E.g. checking if the username is taken or not.
- You may publish this
createForm
as a separate NPM package and reap all the fame that comes with being an open source maintainer :).
... but those things are outside the scope of this guide and are left as an exercise for the reader.
I hope you found this guide useful!
Happy hacking! :D