jaredpalmer / formik

Build forms in React, without the tears ๐Ÿ˜ญ
https://formik.org
Apache License 2.0
33.96k stars 2.79k forks source link

Extensibility Needed - Use Case: Auto-Save & Partial Submit #1506

Open zlanich opened 5 years ago

zlanich commented 5 years ago

๐Ÿš€ Feature request

Current Behavior

Currently, Formik doesn't seem to have quite enough extensibility to implement any sort of Auto-Save or Partial Submit functionality, mostly due to lack of accessibility to a couple internal props.

Desired Behavior

Ability to: Implement Auto-Save (both single value and whole form):

Why Partial Submit?

Use Case: React Native Settings Screens: http://zsl.io/ZB5KYH

This is basically a PATCH operation, where all values go to the same API endpoint, in the same manner, so it makes sense to have them all within the same form so we can take advantage of all of Formik's state and validation features. Obviously we can't wrap every field in a separate Formik form; that would be obnoxious, lol. Also, this is important to avoid having to submit the ENTIRE object of settings on every handleChange; it would be a network performance issue, and increase the chance of settings getting overwritten in the event that the server changes an unrelated setting, then the app submits the ENTIRE settings object right after.

I've also seen people in Formik's issues needing this same functionality for Draft Auto-Save.

Suggested [Potential] Solutions

Return a Promise from handleChange and handleBlur:

Who does this impact? Who is this for?

Anyone:

Describe alternatives you've considered

I'm currently performing this hackish workaround:

It's depressing, really ๐Ÿ˜ข

Additional context

I've also tried using the Field component to provide a validate function, etc, but because of the lack of synchronous nature of running handleChange, then validateField, I'm not able to take advantage of reducing boilerplate there either.

This is extremely important to me, and can be accomplished with very little change to Formik, by only exposing a couple small things (ie. Promise or hook). I've currently had to write my own solution for a ton of this, and the code is quickly turning into a duplicate of Formik, which pains me ๐Ÿ˜–.

I've also looked through all the v2 code to see if there's anything to help there, but I'm not finding anything. I also looked through all of the issues to make sure I wasn't creating a duplicate. See below.

Other issues that could be related or useful:

If anyone has suggestions, or I'm missing something, please let me know!

jaredpalmer commented 5 years ago

Thanks for the detailed write up and use case. Very very helpful. I think it is a good one. FWIW The way we write our REST API's @palmerhq influenced the design of Formik. We often submit the entire settings object on save instead of each field like you described. It's worth considering discussing or explaining that in more depth in a blog post some day. Anyways....

First, I have been making some relevant progress in this PR: #1578. This changes the way that low-pri validation works so as to only validate one field at a time like you described as your desired behavior. Submission and pre-submit validation is still of the entire form. However, you could diff initialValues and values in you onSubmit handler and then you'd know what changed.

Second, I'm not fully certain that your exact use case is a good one for Formik. because you really are not doing anything at the form level at all. If your validation AND submission are on a per-field basis, then there isn't a need for formik's submission process or colocation. Validate on your own and then just call your real submission function directly in your own component. Somethign like this:

const FormContext = React.createContext({}) 

const FormLib = ({children, onSave }) => {
  const ctx = { onSave: onSave, initialValues }
  return (
    <FormContext.Provider value={ctx}>
      {children}
    </FormContext.Provider>
  )
}

const useTextField = ({ name, validate }) => {
  const form = React.useContext(FormContext)
  const [value, setValue] = React.useState(form.initialValues[name])
  const [error, setError] = React.useState(undefined)

  // if you change initialValues, then reset local state
  React.useEffect(() => {
    setValue(form.initialValues[name])
    setError(undefined)
  }, [form.initialValues, name])

  const debouncedSave = debounce(form.onSave, 300)   
  const onChangeText = (text) => {
    setValue(text)
    if (validate) {
      const maybeError = validate(text)
      setError(maybeError)    
      if (!maybeError) {
       debouncedSave(text)
        .then(
         () => console.log('saved at: ' + Date.now()),
         (err) => console.log(err)
        )
      }  
    } 
  }

  return [{ value, onChange }, { error }   
}

const TextField = (props) => {
 const [field, { error }] = useTextField(props)
 return (
    <View>
      <TextInput {...field} {...props} />
      {!!error ? <Text>{error}</Text> : null}
    </View>
  )
}

const Settings = ({ settingsValues }) => {
 return (
   <FormLib 
      initialValues={settingsValues} 
      onSave={value => SaveMyField(value).then(() => console.log('boop')}
   >
     <TextField name="firstName" validate={value => value.trim() != null} />
     <TextField name="lastName" />
   </FormLib>
 )
}

Third, with Hooks / v2, there is no safe way to emit an effect after a commit. This API is not feasible because useState's setter does not give a callback fn and there isn't a way to emit an effect after useReducer has committed the update. My guess is that Fiber/ConcurrentMode makes this kind of API even harder to predict/reason about and the general suggestion is to lift this logic into useEffect. In fact, returning a promise or callback from Formik's setters / handlers would actually be super misleading at the moment in v2 because the promise resolution would not be actually related to the commit of the state update in React. HOWEVER, Sophie did write a useReducerWithEmitEffect hook in a gist that supposedly enables this exact behavior. I'm a little hesitant though to jump on something that is not yet officially supported (even if its from Sophie).

Fourth, diff tracking or submitting changed values has always been something that I expected folks to do on their own inside of onSubmit because that's likely how you would do it on your own in React. Even if we merge #1578, I don't think i would change this.

zlanich commented 5 years ago

Thanks @jaredpalmer! This is all very interesting, and you've brought some things to my attention that I wasn't aware of regarding useEffect() and the misleading promise resolution issue. The example you gave has my gears turning now on an overall better future solution.

I've been on the fence about whether Formik is the best solution for my use case, and here's why:

In settings screens, you have a mixture of independent and dependent fields. Some are best submitted together (like a form), while some are inherently independent. Currently, I'm wrapping these cases in their own separate <SettingsForm> components. Example:

So it pained me to completely reinvent the nice form functionality in Formik just to be able to handle both use cases. Do you have any additional thoughts on this?

Also, I would definitely love to hear your opinion on why you submit the entire settings object rather than PATCH for your REST API. I'm imagining a use case where the settings object might be rather large, and you'd be submitting a bunch of unrelated fields along with the "1" you changed, especially if you're firing off auto-saves one after another.

jaredpalmer commented 5 years ago

Tbh. Another valid solution would be to make each settings item itโ€™s own form.

zlanich commented 5 years ago

@jaredpalmer Yea, that's an interesting thought. I think I was trying to avoid having a ton of repetitive markup something like <SettingsField onSubmit={submitHandler} {...} />, especially since I usually end up putting props on their own lines. Same concept as why you have a <Field /> component to reduce boilerplate -- to avoid having a million props like value, onChange, onBlur, etc on every single field. It's always a double-edged sword, lol.

shamilovtim commented 4 years ago

Hi. Using react-native-material-textfield this is all I had to do to add validation and submission per an individual field.

const [feeValidationError, setFeeValidationError] = useState('');

  const personalFeeValidation = Yup.number()
    .typeError('The fee must be a number.')
    .required(
      `The fee override is not set because the field is empty. Please enter a number or switch back to practice default.`
    )
    .min(0, `The fee cannot be less than zero.`);

 <TextField
                onEndEditing={async (event) => {
                  try {
                    const text = event.nativeEvent.text;
                    const validation = await personalFeeValidation.validate(text);
                    dispatch(patchFee(text, false));
                    console.log(text);
                    setFeeValidationError('');
                  } catch (e) {
                    console.log(e);
                    setFeeValidationError(e.message);
                  }
                }}
                error={feeValidationError}
                placeholder='$0.00'
                prefix='$'
                label='Personal fee override for evisits'
                keyboardType={'number-pad'}
                value={`${state.home.providerPrice}`}
                enablesReturnKeyAutomatically={true}
              />

As you can see, the boilerplate is quite minimal. It's really just:

  1. a try catch
  2. the useState
  3. and setting and resetting the error message

It seems to me that it would be much easier either adding a custom hook or enhancing the textinput of your choice with this boilerplate rather than adding it onto Formik itself. As a matter of fact it seems because Formik is about ONE form, it actively gets in the way of this type of functionality.

And here is a ready made component that does exactly this. You simply pass in a Yup validation and an onValidationSuccess callback function.

import React, { useState } from 'react';
import { TextField, TextFieldProps } from 'react-native-material-textfield';
import * as Yup from 'yup';

interface iValidatedInput extends TextFieldProps {
  validation: Yup.MixedSchema;
  onValidationSuccess: (inputText: string) => void;
}

export const ValidatedInput = (props: iValidatedInput) => {
  const [validationError, setValidationError] = useState('');

  return (
    <TextField
      onEndEditing={async (event) => {
        try {
          const text = event.nativeEvent.text;
          await props.validation.validate(text);
          setValidationError('');
          props.onValidationSuccess(text);
        } catch (e) {
          console.log(e);
          setValidationError(e.message);
        }
      }}
      error={validationError}
      {...props}
    />
  );
};

Used like this:

 const personalFeeValidation = Yup.number()
    .typeError('The fee must be a number.')
    .required(
      `The fee override is not set because the field is empty. Please enter a number or switch back to practice default.`
    )
    .min(0, `The fee cannot be less than zero.`);

  const personalFeeOnSuccessCallback = (inputText: string) => {
    dispatch(patchFee(parseInt(inputText), false));
  };

   <ValidatedInput
                validation={personalFeeValidation}
                onValidationSuccess={personalFeeOnSuccessCallback}
                placeholder='0.00'
                prefix='$'
                label='Personal fee override for evisits'
                keyboardType={'number-pad'}
                value={`${state.home.providerPrice}`}
                enablesReturnKeyAutomatically={true}
              />

Let me know what you think.

zlanich commented 4 years ago

@shamilovtim That sounds pretty reasonable, and would separate all this from Formik. I will experiment with this asap as we're working on the project and respond here when I get a chance. Thank you!

matsgm commented 4 years ago

I'm currently working on a similar auto-save concept with Rest Patch. I need to validate the entire form before moving to the "next page". Still, I want to auto-save on every onBlur.

Here's a suggestion:

const [lastBlurKey, setLastBlurKey] = React.useState(null);
const [lastBlurValue, setLastBlurValue] = React.useState(null);
const [blurObject, setBlurObject] = React.useState(null);

const handleHandleBlur = (event: React.ChangeEvent<HTMLInputElement>) => {
  const newBlurObject = {
    [event.target.id]: event.target.value,
  };
  if (lastBlurKey !== event.target.id || lastBlurValue !== event.target.value) {
    setLastBlurKey(event.target.id);
    setLastBlurValue(event.target.value);
    setBlurObject(newBlurObject);
  }
  handleBlur(event);
};

React.useEffect(() => {
  if (blurObject) {
    // Do something with object, like Rest Patch
  }
}, [blurObject]);
RoosterH commented 4 years ago

Here is how I implement partial save:

useState to control the validation function to be applied for name field. validation is enabled except 'SAVE' button OnClick been triggered. In SAVE OnClick, change state of validation function to no-op. When isSubmitting becomes false, meaning submitting is done, change validation state back to regular validation.

Also I use isSaveButton initialValue combined with setFieldValue to keep track which button 'SAVE' or 'SUBMIT' been clicked.

let initialValues = {
        name: '',
        isSaveButton: false
};
const [validateName, setValidateName] = useState(() => value => {
        let error;
        if (!value) {
            error = 'Event Name is required.';
        }
        return error;
});

<Formik
    enableReinitialize={true}
    initialValues={initialValues}
    onSubmit={(values, actions) => {
        values.isSaveButton
        ? saveHandler(values)
        : submitHandler(values);
        actions.setSubmitting(false);
        if (!actions.isSubmitting) {
            setValidateName(() => value => {
                    let error;
                    if (!value) {
                    error = 'Event Name is required.';
                }
                    return error;
                 }}
}>
{({
     errors,
     handleBlur,
     handleChange,
     setFieldValue,
     touched,
     values 
}) => (
     <Form>
        <Field  
                id="name"
        name="name"
        type="text"
        validate={validateName}
        onChange={handleChange}
        value={values.name}
        onBlur={event => {
            handleBlur(event);
        }}
    />
    {touched.name && errors.name && (
        <div >
            {errors.name}
        </div>
    )}
        <Button
            type="submit"
        onClick={e => {
            setFieldValue('isSaveButton', true, false);
            setValidateName(() => () => {});
    }}>
    Save
    </Button>
    <Button
        type="submit"
        onClick={e => {
            setFieldValue('isSaveButton', false, false);
            handleSubmit(e);
    }}>
    Submit
    </Button>
  </Form>
</Formik>