Reading through a project: Formy
Sometimes it helps to take a software project and just read through the source code. If the documentation is good enough or the interface is simple enough you can probably getting away with not knowing how most of the project works, but sometimes it’s kind of nice to look a little deeper. I’ve used a React form library called Formy for a few projects at iFixit. Formy lets you configure a form using JavaScript objects and apply that configuration to a view using React components. The documentation has a lot of examples, which makes it really easy to get up and running with it, but to be honest I don’t really understand how most of it works. Here’s my attempt at learning a little bit more about it.
Where to start
It’s probably not a bad idea to start looking in the entrypoint of the module. In the package.json file that’s specified by the main field of the JSON document. For Formy, it’s dist/Formy/Form.js, but that file doesn’t show up in GitHub. The dist directory is the result of a build step which converts each file in the src directory to an ES5 target, so it’s safe to say that we can treat src/Formy/Form.js as the entrypoint. The src/example and src/index.js directories and files are only used for documentation and development, so those can be ignored.
Exports
Form.js is responsible for exporting functions and data that users of the library can access. The file specifies a default export named Form, which is an object which contains named functions. It doesn’t look like Form has any state or prototype (apart from the Object prototype), so the functions it holds can be viewed as static functions and can be looked at individually.
Form.Component
Form.Component = ({ id, name, onSubmit, children }) => ( | |
<form | |
id={id} | |
name={name} | |
onSubmit={onSubmit} | |
> | |
{children} | |
</form> | |
); | |
Form.Component.propTypes = { | |
id: PropTypes.string, | |
name: PropTypes.string, | |
onSubmit: PropTypes.func, | |
}; |
Component is a functional React component that takes id, name, onSubmit, and children as props. The return value of that functional component is a form with those props applied to it. Any child components that are included within Form.Component are passed through to the form component. That’s probably used for including form inputs or submit buttons as children to a form.
Component seems like a kind of general name for a React component. Maybe it would be better to name it Form, because it wraps an actual form JSX tag.
Form.Field
Form.Field is defined in a separate file, so I’m not totally sure what that means yet. Why is FormField in a different file, but not Form.Component? That might make things seems a little more consistent. We can revisit this later after going through Form.js.
Form.customValidityFactory
Form.customValidityFactory = (constraint, validationMessage = 'Invalid') => (...args) => ( | |
constraint(...args) ? '' : validationMessage | |
); |
The end result of the customValidityFactory is to call setCustomValidity on the form input with the string result from calling the constraint function on the arguments passed to the resulting function. However, this happens in the component library and not in Formy itself. Formy assumes that passing a customValidity property to an input component will handle that properly, so that’s important to know if you want to include your own component library to use with Formy.
Form.fields
Form.fields = (globalProps = {}, fields) => Object.assign({}, | |
...Object.entries(fields).map(([fieldKey, field]) => ({ | |
[fieldKey]: { | |
...Form.Field.defaultProps, | |
...{ name: fieldKey }, | |
...globalProps, | |
...field, | |
}, | |
})), | |
); |
Form.getData
Form.getData = form => Object.assign({}, | |
...Object.entries(Form.getProps(form).fields) | |
.filter(([fieldKey, field]) => !field.disabled) | |
.filter(([fieldKey, field]) => | |
!['checkbox', 'radio'].includes(field.type) || field.checked | |
) | |
.map(([fieldKey, field]) => ({ [fieldKey]: field.value })), | |
); |
Form.getProps
Form.getProps = form => Object.assign({}, | |
...Object.entries(form) | |
.filter(([formPropKey, formProp]) => formPropKey !== 'fields') | |
.map(([formPropKey, formProp]) => ({ | |
[formPropKey]: formProp instanceof Function ? formProp(form) : formProp, | |
})), | |
{ | |
fields: Object.assign({}, ...Object.entries(form.fields).map(([fieldKey, field]) => ({ | |
[fieldKey]: Object.assign({}, ...Object.entries(field).map(([fieldPropKey, fieldProp]) => ({ | |
[fieldPropKey]: fieldProp instanceof Function ? fieldProp(form, fieldKey) : fieldProp, | |
}))), | |
}))), | |
}, | |
); |
For all the ES6+ magic going on here, we’re basically mapping an object full of form level props and transforming properties that are functions by applying them with the form object and a fieldKey (if it’s a form field property).
Wow there’s a lot going on here. From examples it looks like this returns a list of props that can be passed to Form.Component and Form.Field in user component’s render method.
This function (and Form.getData) makes pretty heavy use of Object.assign. What does Object.assign actually do?
Object.assign is like an object spread operator. The first argument is the target object and all other arguments are sources to copy fields from into the target object. Later source properties override earlier ones. It looks like most of its uses use an empty target object and a list of sources from global to more specific properties. Object.assign can also take a source that is an array of objects and it will merge those together and then copy those into the target object.
The project’s babelrc specifies using the transform-object-rest-spread plugin, so maybe those Object.assigns can be converted to use the object spread operator.
Form.onChangeFactory
Form.onChangeFactory = fn => (form, fieldKey) => updatedProps => fn({ | |
...form, | |
fields: { | |
...form.fields, | |
[fieldKey]: { | |
...form.fields[fieldKey], | |
...updatedProps, | |
}, | |
}, | |
}); |
The example handler function receives a new form object with the updated fields and calls setState with that new form state. Kind of interesting that you have to specify that in order for the form to work. Maybe it could be a nice default.
Form.onSubmitFactory
Form.onSubmitFactory = fn => form => ev => { | |
ev.preventDefault(); | |
fn(Form.getData(form)); | |
}; |
The resulting function from calling Form.onSubmitFactory is used as the value for the onSubmit key in the form state. The Form.Component component needs a onSubmit function that takes an event. In order to convert the onSubmit function in the form state into the onSubmit function prop, call From.getProps on the form state. This will supply the form state to the onSubmit function in the state, which takes a form and returns a function that takes an event. The result from calling that function will.
FormField.js
import React from 'react'; | |
import FormFieldPropTypes from './FormFieldPropTypes'; | |
import FormDefaultComponentLibrary from './FormDefaultComponentLibrary'; | |
const FormField = ({componentLibrary, ...props}) => { | |
const Component = componentLibrary[props.type]; | |
return <Component {...props} />; | |
} | |
FormField.defaultProps = { | |
checked: false, | |
componentLibrary: FormDefaultComponentLibrary, | |
type: 'text', | |
value: '', | |
}; | |
FormField.propTypes = FormFieldPropTypes; | |
export default FormField; |
FormField specifies some defaultProps such as checked, componentLibrary, type, and value. Checked is false by default, componentLibrary is Toolbox by default, type is text by default, and value is empty string by default. Not too weird for defaults.
FormField’s propTypes are imported from the FormFieldPropTypes.js file. Maybe that’s something that would be better specified by the component library? I’m not sure.