davezuko / react-reformed
- суббота, 16 июля 2016 г. в 03:12:31
JavaScript
Make React forms simple again, see it here:
Tiny form bindings for React so you can stop storing your form data in local component state. This higher-order component wraps your component and, through props, injects form data (a model
) and simple bindings to update that model, giving you the opportunity to utilize composition in a middleware-like approach to accomplish more advanced functionality without bloating the form component itself.
There is no framework here, it's about 75 lines of code that you could write yourself in a few minutes. The code is not what is important. My goal is to encourage developers to stop using local state in their forms, and do so without locking themselves into a prescriptive, and potentially monolithic, form library. There are some really cool things you can do with simple composition, and this project is an attempt to shine some light on those alternative approaches.
This library does not concern itself with submission, validation, or anything of that sort (though there are demos to show how these can be done) -- it's just a simple read/write API, and isn't even all that specific to forms. Everybody's forms are different, and I'm not smart enough to create a universal abstraction. As such, over time, I've found it easy to encapsulate the core logic of a form (setting properties on a model) in a single component and leverage composition to perform more intricate functionality.
Controlled components are great, they allow you to perform realtime validation, transform user inputs on the fly, track changes over time, and generally improve developer and user experience. However, often times controlled components lead to a proliferation of local component state. You begin tracking validation states (should we be validating yet? Are we performing asynchronous operations?), submission states, and more. Before you know it, a small form component is a sizeable tangle of local state. What happens when something else needs access to that state? How do you incorporate shared validation logic?
Some libraries solve this with refs, others offer bindings onto local state, and still more tout a Comprehensive Form Solution with their own components and validation systems, but none of them feel right. Either they do too much, are too prescriptive, or just get in the way. Chances are, at some point these libraries will no longer fit your use case, forcing you to fight against rather than work with them. That is why this project is more an appeal to a different way of thinking than a complete form library.
With the approach offered here, because everything important to your form now lives in props, you can easily:
setModel
/setProperty
/etc. calls in testingMost importantly, this approach allows for a pluggable/composeable ecosystem, rather than a One Solution To Rule Them All (but will soon change) approach.
If you want to play around with this in a sandbox, there's a working demo in the repo. It's the same one hosted here. Get your own copy by doing the following:
git clone git@github.com:davezuko/react-reformed # Get that thing!
cd react-reformed # Get in there!
npm i # Install its things!
npm start # Start the thing!
# You can now find the app at http://localhost:8080
npm i --save react-reformed
Then just import it and wrap your form component:
import reformed from 'react-reformed'
// Here's an example of a form that closely resembles basic form implementations
// that rely on `this.state`, but uses the form bindings instead.
class MyForm extends React.Component {
_onSubmit = (e) => {
e.preventDefault()
this.props.onSubmit(this.props.model)
}
// This method is essentially just `this.props.bindToChangeEvent`,
// which is provided by the reformed wrapper. We're just demoing
// `setProperty` for clarity in the first example.
_onChangeInput = (e) => {
// `setProperty` is injected by reformed
this.props.setProperty(e.target.name, e.target.value)
}
render () {
// model is injected by reformed
const { model } = this.props
return (
<form onSubmit={this._onSubmit}>
<input name='firstName' value={model.firstName} onChange={this._onChangeInput} />
<input name='lastName' value={model.lastName} onChange={this._onChangeInput} />
<input name='dob' type='date' value={model.dob} onChange={this._onChangeInput} />
<button type='submit'>Submit</button>
</form>
)
}
}
// Wrap your form in the higher-order component
export default reformed()(MyForm)
You can also grab some of the example higher-order components via the npm package. These are just for demonstration, so they are not included in the main export. Use them just to give yourself some ideas!
import compose from 'react-reformed/lib/compose'
import syncWith from 'react-reformed/lib/syncWith'
import validate from 'react-reformed/lib/validate'
import validateSchema from 'react-reformed/lib/validateSchema'
This library provides some simple bindings to speed up form creation. These are for convenience only; you can get by with just setProperty
and setModel
if you'd like. I encourage you to write your own abstractions over these core setters.
import reformed from 'react-reformed'
// "model" and "bindInput" both come from reformed
const MyForm = ({ bindInput }) => {
<form onSubmit={/* ... */}>
<input type='text' {...bindInput('name')} />
<input type='date' {...bindInput('dob')} />
<textarea {...bindInput('bio')} />
<button type='submit'>Submit</button>
</form>
)
const MyFormContainer = reformed()(MyForm)
// Then, use it just like you would in any other component
class Main extends React.Component {
_onSubmit = (model) => {
// do something...
}
render () {
return (
<MyFormContainer
initialModel={{ firstName: 'Michael', lastName: 'Scott' }} // provide an initial model if you want
submit={this._onSubmit}
/>
)
}
}
Here are just some ideas to get you thinking about what's possible when you stop isolating form data in local state and allow it to flow through props. These example are not necessarily production-ready, as they are simply meant as conceptual demonstrations.
This is an ultra-simple higher-order component for synchronous form validation. It is in no way specific to this library, all it does is expected a model
prop and apply additional isValid
and validationErrors
props based on how the model conforms to the validation rules.
compose(
reformed(),
validate([
isRequired('firstName'),
isRequired('lastName'),
mustBeAtLeast('age', 18)
])
)(YourFormComponent)
You could totally go above and beyond and implement something like this:
compose(
reformed(),
validateSchema({
firstName: {
type: 'string',
required: true
},
lastName: {
type: 'string',
required: true
},
age: {
test: (value) => {
return age && age > 18
}
}
})
)(YourFormComponent)
And no matter what you choose to do, your form component never changes.
// treats `rules` as a tuple of [validator: Function, validationError: string]
// `validationError` could easily be a function, if you wanted, for more
// advanced error messages.
const validate = (rules) => (WrappedComponent) => {
const getValidationErrors = (model) => rules.reduce((errors, [rule, err]) => {
return !rule(model) ? errors.concat(err) : errors
}, [])
return (props) => {
const validationErrors = getValidationErrors(props.model)
return React.createElement(WrappedComponent, {
...props,
isValid: !validationErrors.length,
validationErrors,
})
}
}
const isRequired = (prop) => ([
(model) => !!model[prop],
`${prop} is a required field.`
])
const mustBeAtLeast = (prop, val) => ([
(model) => model[prop] >= val,
`${prop} must be at least ${val}`
])
The model is never mutated, so it's easy to check when it's been changed.
compose(
reformed(),
tracker
)(YourFormComponent)
// You could easily expand this to implement time travel
const tracker = (WrappedComponent) => {
class Tracker extends React.Component {
constructor (props, ctx) {
super(props, ctx)
this.state = {
history: [],
}
}
componentWillMount () {
if (this.props.model) {
this.addToHistory(this.props.model)
}
}
componentWillReceiveProps (nextProps) {
if (this.props.model !== nextProps.model) {
this.addToHistory(nextProps.model)
}
}
addToHistory = (model) => {
this.setState({ history: this.state.history.concat(model) })
}
render () {
return React.createElement(WrappedComponent, {
...this.props,
history: this.state.history,
})
}
}
return Tracker
}
// Easily set initial form state from your redux store...
// and bind a submission handler while you're at it.
compose(
connect(
(state) => ({ initialModel: state.forms.myForm.cachedModel }),
{ onSubmit: mySubmitFunction }
),
reformed()
)(YourFormComponent)
If you want to persist your form in Redux over time, you don't even need reformed
. By following its pattern of simple model setters, you can just fulfill the same interface with connect
and have a redux-ified form without needing a redux-specific form library (or any other framework/implementation). The best part is, if you switch to something else down the road you won't need to unreduxify the form, just its container.
connect(
(state) => ({ model: state.form.myForm.model }),
(dispatch) => ({
setProperty: (prop, value) => dispatch(setFormProperty('myForm', prop, value)),
// etc...
})
)(YourFormComponent)
This, again, is a simplified example. You could very easily implement a debounce or throttle function to limit how often the data is written to local storage.
compose(
reformed(),
syncAs('my-form-state')
)(YourFormComponent)
const syncAs = (storageKey) => (WrappedComponent) => {
class SyncedComponent extends React.Component {
componentWillMount () {
const fromStorage = localStorage.getItem(storageKey)
if (fromStorage) {
// probably want to try/catch this in a real app
this.props.setModel(JSON.parse(fromStorage))
}
}
componentWillReceiveProps (nextProps) {
if (this.props.model !== nextProps.model) {
localStorage.setItem(storageKey, JSON.stringify(nextProps.model))
}
}
// or, maybe you only want to sync when the component is unmounted
componentWillUnmount () {
localStorage.setItem(storageKey, JSON.stringify(this.props.model))
}
render () {
return React.createElement(WrappedComponent, this.props)
}
}
// hoist statics, wrap name, etc.
return SyncedComponent
}
Notice anything interesting about the local storage example? It's not at all specific to local storage...
compose(
reformed(),
syncWith(
'my-form',
(key) => JSON.parse(localStorage.getItem(key)),
(key, value) => localStorage.setItem(key, JSON.stringify(value))
)
)(MyFormComponent)
const syncWith = (key, get, set) => (WrappedComponent) => {
class SyncedComponent extends React.Component {
componentWillMount () {
const fromStorage = get(key, this.props)
if (fromStorage) {
this.props.setModel(fromStorage)
}
}
// When we call `set` we can provide the current props as a
// third argument. This would be useful, for example, with other
// higher-order components such as react-redux.
componentWillReceiveProps (nextProps) {
if (this.props.model !== nextProps.model) {
set(key, nextProps.model, nextProps)
}
}
render () {
return React.createElement(WrappedComponent, this.props)
}
}
// ...
return SyncedComponent
}
reformed : (Props -> Props) -> ReactComponent -> ReactComponent
Wraps a React component and injects the form model and setters for that model. You can optionally pass in a function to the first reformed
call that will transform the props that reformed
applies to the wrapped component. This is really just for experimentation and to keep the API open for the future.
Example:
class YourForm extends React.Component {
/* ... */
}
reformed()(YourForm)
setProperty : (String k, v) -> {k:v}
Injected by the reformed
higher order component. Allows you to set a specific property on the model.
Example:
this.props.setProperty('firstName', 'Billy')
setModel : {k:v} -> {k:v}
Injected by the reformed
higher order component. Allows you to completely override the model.
Example:
this.props.setModel({
firstName: 'Bob',
lastName: 'Loblaw'
})
bindInput : String k -> Props
Injected by the reformed
higher order component. Applies name
, value
, and onChange
properties to the input element. Because this does not have a ref to your component or know anything other than the name of the model property, it cannot handle every possible scenario. As such, this should mostly just be used for simple text
inputs where the event target's name and value can be used to update the property.
When other use cases arise, it's recommended to just use setProperty
or setModel
directly or extend reformed
to provide the bindings you need.
Example:
<input {...this.props.bindInput('firstName') />