Search code examples
javascriptreactjsformik

Preventing re-initialisation of one Formik field upon child component state change


I have a parent component which is a form based on Formik, an extract of which is below:


const MembersCreate = memo(() => {
 
  // Variable to store  url for GDPR form, returned after successful upload to Cloudinary
  const urlGDPR = useRecoilValue(urlGDPRState);
  

  // Formik initial values
  const initialValues = {
    forename: '',
    surname: '',
    gdpr_form_link: urlGDPR ? urlGDPR : '',
......

  // Yup field validation
    const validationSchema = Yup.object().shape({
      forename: Yup.string()
        .required('*Forename is required')
        .max(35, 'Forename can be a maximum of 35 characters'),

      surname: Yup.string()
        .required('*Surname is required')
        .max(35, 'Surname can be a maximum of 35 characters'),

      gdpr_form_signed: Yup.string().required(
        '*GDPR form signed is a required field'
      ),

      gdpr_form_link: Yup.string().when(['gdpr_form_signed'], {
        is: (gdpr_form_signed) => gdpr_form_signed === 'Yes',
        then: Yup.string().required(
          '*A link to the signed GDPR form is required'
        ),
      }),
.....

  return (
    <div className="createMemberPage px-5 relative">
      <Formik
        enableReinitialize={true}
        initialValues={initialValues}
        validationSchema={validationSchema}
        onSubmit={(values, formik) => {
          mutate(values, {
            onSuccess: () => {
              setMessage('New member created!');
              formik.resetForm();
            },
            onError: (response) => {
.....

        <Form className="formContainer">
          <h1 className="pb-3 text-xl font-semibold">Contact Information</h1>


          <div>
            <CustomTextField name="forename" label="Forename" />
          </div>

          <div>
            <CustomTextField name="surname" label="Surname" />
          </div>
....

          <div>
            //Custom component to upload file to Cloudinary
            <UploadWidget />
          </div>

          <div>
            <CustomTextField name="gdpr_form_link" label="GDPR Form Link" readOnly={true}/>
          </div>


          <div className="flex flex-col items-center pt-7">
            <Button variant="contained" size="large" type="submit">
              Create Member
            </Button>
          </div>

The initialValues are initially set based on the content of the initialValues const. A user then enters data in the form fields, then half-way down the form they click a button which runs the UploadWidget child component. This code is based on fairly standard code from Cloudinary:


const UploadWidget = () => {
  const cloudinaryRef = useRef();
  const widgetRef = useRef();
  const [, setUrlGDPR] = useRecoilState(urlGDPRState);

  useEffect(() => {
    cloudinaryRef.current = window.cloudinary;
    widgetRef.current = cloudinaryRef.current.createUploadWidget(
      {
        cloudName: '*removed*',
        uploadPreset: '*removed*',
        sources: ['local', 'camera'],
        cropping: true,
        multiple: false,
        resourceType: 'image',
      },
      function(error, result) {
        if (result && result.event === 'success') {
          setUrlGDPR(result.info.secure_url);
        }
      }
    );
  });

  const handleButtonClick = () => {
    widgetRef.current.open();
  };

  return (
    <div className="flex flex-col items-center width-full pb-4">
      <Button
        variant="contained"
        size="large"
        onClick={handleButtonClick}
        fullWidth={true}
      >
        Upload Signed GDPR Form
      </Button>
    </div>
  );
};

export default memo(UploadWidget);

This component uploads an image to Cloudinary using their upload widget, which returns a url for the uploaded image which I store in Recoil state urlGDPRState. That Recoil state is then accessed from the original parent component, and I need to use it to populate the field in the form called gdpr_form_link. The reason for populating that field is so that the user can see the upload was successful, and so that the url can be saved to my database when the overall form is submitted.

Currently I am achieving that by setting the gdpr_form_link field's value using initialValues, along with setting Formik's enableReinitialize function to true. This successfully updates the gdpr_form_link with the url, but also nulls any data a user had entered in any other field in the form (I think it is basically reinitialising all Formik fields to their initial values).

Is there a way I can change this so only the gdpr_form_link is updated? I have been trying various things, but couldn't get a working solution using initialValues. I then thought perhaps I could use Formik's setFieldValue function to set the content of gdpr_form_link, but couldn't get it working. Plus, every solution I could come up with using that would require enableReinitialize to be set to false, but doing that would stop the overall form from being reset after a successful submit, which is required functionality.

I've also read through tens of S.O.issues, but couldn't see an answer (I could see a duplicate of my question, but it wasn't answered). Any ideas appreciated.


Solution

  • Came up with a solution. Instead of using Recoil to create a global state that was used to pass the url from child component (the upload widget) to parent, I used Formik's setFieldValue function instead. But rather than using that function within the parent as I had been attempting previously, I followed the advice in this solution and moved that function inside the child component, setting the formik field in the parent component directly from its child. Revised code:

    import { useEffect, useRef, memo } from 'react';
    import Button from '@mui/material/Button';
    import { useFormikContext } from 'formik';
    
    const UploadWidget = () => {
      const cloudinaryRef = useRef();
      const widgetRef = useRef();
      const { setFieldValue } = useFormikContext();
    
      useEffect(() => {
        cloudinaryRef.current = window.cloudinary;
        widgetRef.current = cloudinaryRef.current.createUploadWidget(
          {
            cloudName: 'removed',
            uploadPreset: 'removed',
            sources: ['local', 'camera'],
            cropping: true,
            multiple: false,
            resourceType: 'image',
          },
          function (error, result) {
            if (result && result.event === 'success') {
              // Set the value of gdpr_form_signed field in the parent Formik form
              setFieldValue('gdpr_form_link', result.info.secure_url);
            }
            //console.log('result=', result);
          }
        );
      }, [setFieldValue]);
    
      const handleButtonClick = () => {
        widgetRef.current.open();
      };
    
      return (
        <div className="flex flex-col items-center width-full pb-4">
          <Button
            variant="contained"
            size="large"
            onClick={handleButtonClick}
            fullWidth={true}
          >
            Upload Signed GDPR Form
          </Button>
        </div>
      );
    };
    
    export default memo(UploadWidget);