Skip to content

Instantly share code, notes, and snippets.

@oyeanuj
Last active July 2, 2020 00:57
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save oyeanuj/2f69ebe1004e2ff47d7550d957bf05cf to your computer and use it in GitHub Desktop.
Save oyeanuj/2f69ebe1004e2ff47d7550d957bf05cf to your computer and use it in GitHub Desktop.
Auto-saving forms using Redux-Form

Here is how I ended up making this work. For more context and background on the discussion, I recommend reading conversation in ReduxForm#2169 (and chiming in with your learnings, etc).

So, as per the conversation there, I tried the 'many little forms' approach. I am sharing both my approach, and a crude abstracted out gist that can hopefully help out y'all.

Essentially, with this approach:

  1. You want to use each field as a little form which submits debounced onChange.
  2. For each field as a little form, you want to abstract that into a component which can be provided an input, as much as possible.
  3. Set autofocus for the first form.
  4. For debounced onChange, I used react-debounced-input.
  5. You can also use components which are not forms, as long as you control and can compute submit, onChange, error states, etc.
  6. Many little form also meant, that there is no parent form, atleast not in my case.
  7. The implication of that is that you have to compute submitting state and/or error state yourself.
  8. When #2289 (#2173) make it to a release, computing the submitting would get a bit easier. Until then, I would recommend storing that in your Redux state when you make the XHR call.
  9. My recommendation would be to set up a selector that aggregates all of your SingleFieldForm states into a single prop, in a similar fashion to how Redux setups the values props, or state of a larger form.

In my case, where there are a handful of fields, it works well. With larger forms, YMMV.

-- Finally, a few assumptions to know while looking at the Gist:

  1. My error rendering requirements were slightly more indepth than typical forms. I'm trying to show errors both around the submit button as well as near the field.
  2. The gist is simplified but works for all kind of components. In my form, there was a dropdown form, deletable fields, textareas, and it worked well.
  3. Sprinkling of lodash function for simplicity.

--

Hope this helps folks - if you have better approaches, do share. Personally, I am confident that as soon as one can do field level submission (#2169), this would become much more trivial.

import { Field } from 'redux-form';
import DebounceInput from 'react-debounce-input';
export default class InputWithLabel extends Component {
static propTypes = {
/*
... Regular Props ...
Next props are the more important for this example
*/
onChangeSubmit: PropTypes.func,
handleSubmit: PropTypes.func,
value: PropTypes.any,
autoFocus: PropTypes.bool
};
static defaultProps = {
autoFocus: false
};
renderDebouncedInput = (field) => {
const { handleSubmit, onChangeSubmit, showInlineError, displayName } = this.props;
/*Only need this if you want to show error alongside the field */
const renderFieldError = () => {
const errorMsg = field.meta.error && `${displayName} ${field.meta.error[0]}`;
const validError = field.meta.touched && hasSomething(field.meta.error) && !field.meta.submitting && !field.meta.active;
return (
<FormError type = "field" errorMsg = {errorMsg} showIfError = {validError} />
);
};
const changeWithSubmit = (event) => {
field.input.onChange(event.target.value);
handleSubmit((values, dispatch, props) => {
const newValues = values;
newValues[field.name] = event.target.value;
return onChangeSubmit(newValues, dispatch, props);
})();
};
return (
<span>
<DebounceInput
minLength = {1}
debounceTimeout = {500}
value = {field.input.value}
onFocus = {field.input.onFocus}
onBlur = {field.input.oBlur}
onChange = {changeWithSubmit}
/>
{ renderFieldError() }
</span>
);
};
render() {
const {
type, name, value, label, placeholder, size, style, disabled, autoFocus,
onClick, onChangeSubmit, submitOnChange, useDebouncedInput, handleSubmit
} = this.props;
return (
<div>
<Field
component = {this.renderDebouncedInput}
type = {type}
id = {name}
name = {name}
aria-label = {label}
value = {value}
autoFocus = {autoFocus}
/>
<label htmlFor = {name}>{label}</label>
</div>
);
}
}
export const getFormState = (state) => state.form;
export const getAutoSavingFormState = createSelector(
getFormState,
(formState) => pickBy(formState, (value, key) => key.includes('autoSavingForm'))
);
@reduxForm({ form: 'singleFieldForm' })
export default class SingleFieldForm extends Component {
static propTypes = {
//Data
children: PropTypes.object,
autoFocus: PropTypes.bool,
handleSubmit: PropTypes.func.isRequired,
onChangeSubmit: PropTypes.func.isRequired,
valid: PropTypes.bool.isRequired,
invalid: PropTypes.bool.isRequired,
submitting: PropTypes.bool.isRequired,
dirty: PropTypes.bool.isRequired,
submitFailed: PropTypes.bool.isRequired,
anyTouched: PropTypes.bool,
error: PropTypes.any,
form: PropTypes.string,
fieldName: PropTypes.string,
//Functions
dispatch: PropTypes.func
};
static defaultProps = {
autoFocus: false
};
render() {
const styles = require('./Forms.scss');
const {
form, children, fieldName, displayName,
invalid, anyTouched, submitFailed, submitting, error,
handleSubmit, onChangeSubmit
} = this.props;
const renderSpinner = () => (
<div className={styles.overlay}>Submitting Overlay</div>
);
const formClass = classNames(
"singleFieldForm",
{ error: isString(error) && anyTouched },
{ validated: !error && anyTouched }
);
return (
<form className = {styles[formClass]} >
{
/*
Providing the actual submit function as well as handleSubmit
to the child passed in the form above
*/
React.cloneElement(
children,
{
onChangeSubmit: onChangeSubmit,
handleSubmit: handleSubmit
}
)
}
{ submitting && renderSpinner() }
</form>
);
}
}
export default class BasicMemberDetails extends Component {
static propTypes = {
/*
... Regular props
formState: connects to Redux state
and aggregates all the 'SingleFieldForm' in one prop.
Look at selector.js below for how.
*/
formState: PropTypes.object
};
updateField = (values, dispatch, props) => {
//console.log("Prop Valid:", props.valid, "Prop Dirty:", props.dirty);
if (props.valid && props.dirty && !props.submitting) {
const fieldName = props.form.split('-')[1];
const actionCreator = this.props.actions[`update${upperFirst(fieldName)}`];
return actionCreator(
props.initialValues[fieldName],
values[fieldName],
this.props.loggedInUser.handle
)
.catch((response) => {
const error = response.body && response.body[fieldName];
console.log('the error is ', error);
const errorObj = {
_error: `There is an error with the ${props.displayName} that you've entered.`
};
errorObj[fieldName] = error;
throw new SubmissionError(errorObj);
});
}
};
validateFirstName = (values, props) => {
const errors = {};
if (values && (hasNothing(values.firstName) || !isString(values.firstName)) ) {
errors.firstName = ["cannot be blank."];
}
return errors;
};
validateLastName = (values, props) => {
const errors = {};
if (values && values.lastName && !isString(values.lastName)) {
errors.lastName = ["doesn't seem to be valid."];
}
return errors;
};
/* Similarly, you need a validate function for each 'SingleFieldForm' */
render() {
const { actions, loggedInUser, formState, overallSubmitFunction } = this.props;
const userInfo = loggedInUser.info;
const initialValues = {
firstName: userInfo.first_name,
lastName: userInfo.last_name
};
/*
Compute submitting state using
all the flags you might have in Redux,
for eg: when making XHR calls
*/
const submitting = some(
[
loggedInUser.updatingFirstName,
loggedInUser.updatingLastName
],
Boolean
);
/*
This function is critical - it replicates basic Redux-Form functionality
to determine if any of the little forms are submitting or have error.
*/
const canMoveToNextStep = hasSomething(formState) && !submitting && every(
formState,
(formDetails, formName) => {
const fieldName = formName.split('-')[1];
const { initial, values, syncErrors, submitSucceeded } = formDetails;
const isDirty = initial[fieldName] != values[fieldName];
return (isDirty && submitSucceeded) || (!isDirty && !syncErrors);
}
);
console.log("canMoveToNextStep? ", canMoveToNextStep);
/* This function computes which field have errors, and then returns an error message */
const errorMsg = () => {
const errorList = reduce(
formState,
(result, formDetails, formName) => {
/* SingleFieldForm names used a naming convention for simplicity */
const fieldName = formName.split('-')[1];
const { error, syncErrors } = formDetails;
if (isntEmptyString(error)) {
result[fieldName] = error;
} else if (syncErrors && hasSomething(syncErrors[fieldName])) {
result[fieldName] = syncErrors[fieldName][0];
}
return result;
},
{}
);
const fieldsWithError = Object.keys(errorList);
return 'Some error message that you construct using the information computed in this function'.
};
return (
<div className = {styles.autoSavingForm}>
<h3>This is an auto-saving form</h3>
<SingleFieldForm
form = "autoSavingForm-firstName"
fieldName = "firstName"
onChangeSubmit = {this.updateField}
validate = {this.validateFirstName}
initialValues = {{ firstName: initialValues.firstName }}
destroyOnUnmount = {false}
enableReinitialize
keepDirtyOnReinitialize
autoFocus
>
<InputComponent {/* props for that field */} />
</SingleFieldForm>
<SingleFieldForm
form = "autoSavingForm-lastName"
fieldName = "lastName"
onChangeSubmit = {this.updateField}
validate = {this.validateLastName}
initialValues = {{ lastName: initialValues.lastName }}
destroyOnUnmount = {false}
enableReinitialize
keepDirtyOnReinitialize
>
<InputComponent {/* props for that field */} />
</SingleFieldForm>
{/*Example of a non-form component in this component */}
<ReactSuperSelect
controlId = "someId"
name = "someName"
dataSource = {someList}
clearable = {false}
onChange = {this.someOnChangeFunction}
/>
{/*Only need the line below if you want to show form level error */}
<FormError errorMsg = {errorMsg()} />
<Button
ref = "autoSavingFormButton"
label = {submitting ? "Saving" : "Next!" }
submitting = {submitting}
type = {canMoveToNextStep ? "submit" : "disabled"}
onClick = {overallSubmitFunction}
/>
</div>
);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment