Formik: How to warn user with unsaved changes?

Created on 8 Jul 2019  路  10Comments  路  Source: formium/formik

As title.
I come from react-final-form and trying to do migration.
I have multiple forms and would like to warn user whether any of the forms in the page is "dirty".
How could I do that?
Previously, I do that using FormSpy in react-final-form to sync the dirty attribute to a React Context Provider. When user navigate out with React Router and if the context value is dirty, set the dialog to open.

Formik User Land Question

Most helpful comment

Here it is with react-router-dom's <Prompt>

https://codesandbox.io/s/formik-x-react-router-prompt-kvxt4

All 10 comments

Does this work?

```jsx
import React from 'react'
import { Prompt } from 'react-router-dom'
import { FormikConsumer } from 'formik'

export const PromptIfDirty = () => (

{formik =>

)

But what I want is prompt only when user trying to navigate to other pages without saving the current forms.

I can do it right now, but I am not sure it is right way to do this as I tried to store all the formik objects in a page to a React context. It seems violate that "we should not store the form state anywhere outside the form component"....

Here is how I do it...

AggregateRoot aggregate all the formik state to the React Context

// AggregateRoot.tsx
import React from 'react';
import produce from 'immer';
import { FormikContext } from 'formik';
import AggregateContext, { AggregateState } from './AggregateContext';

export interface AggregateRootProps {
  children: React.ReactNode;
};

const AggregateRoot: React.ComponentType<AggregateRootProps> = props => {
  const { children } = props;
  const idRefs = React.useRef<Set<string>>(new Set());
  const [formStates, setFormStates] = React.useState<AggregateState>({});

  const createFormState =  React.useCallback((id: string) => {
    setFormStates(produce((draft: AggregateState) => {
      if (!idRefs.current.has(id)) idRefs.current.add(id);
      else throw new Error(`form ${id} is already created before, you may trying to create form that with duplciate id which is not allowed.`);
    }));
  }, []);

  const setFormState = React.useCallback((id: string, state: FormikContext<any>) => {
    setFormStates(produce((draft: AggregateState) => {
      if (idRefs.current.has(id)) draft[id] = state;
      else throw new Error(`form ${id} does not exist, make sure you have called \`createFormState\` before this action.`);
    }));
  }, []);

  const clearFormState = React.useCallback((id: string) => {
    setFormStates(produce((draft: AggregateState) => {
      if (!idRefs.current.has(id)) throw new Error(`form ${id} does not exist, make sure you have put the correct id for this action.`);
      delete draft[id];
      idRefs.current.delete(id);
    }));
  }, []);

  const actions = { createFormState, setFormState, clearFormState };
  const value = [formStates, actions] as [typeof formStates, typeof actions];

  return (
    <AggregateContext.Provider value={value}>
      {children}
    </AggregateContext.Provider>
  );
};

export default AggregateRoot;
// Aggregate Context
import React from 'react';
import { FormikContext } from 'formik';

export type AggregateActions = {
  createFormState(id: string): void;
  setFormState(id: string, state: FormikContext<any>): void;
  clearFormState(id: string): void;
};

export type AggregateState = Record<string, FormikContext<any>>;

export type AggregateContextValue = [AggregateState, AggregateActions];

const AggregateContext = React.createContext<AggregateContextValue>(null as any);

export default AggregateContext;
// useAggregateContext
import React from 'react';
import AggregateContext from './AggregateContext';

export default () => {
  const ctx = React.useContext(AggregateContext);
  if (ctx === null) {
    throw new Error(
      `useAggregateContext hook can only be called inside the body of a function component ` + 
      `that is descendant of AggregateRoot.`
    );
  }
  return ctx;
};

AggregateEffect is supposed to be rendered inside Formik Form component with custom form id, it would send the formik object to the AggregateRoot.

// AggregateEffect.tsx
import React from 'react';
import { connect, FormikContext } from 'formik';
import { useAggregateContext } from '../AggregateRoot';

export interface AggregateEffectProps {
  id: string;
};

interface AggregateEffectInnerProps extends AggregateEffectProps {
  formik: FormikContext<any>;
}

const AggregateEffect: React.ComponentType<AggregateEffectInnerProps> = props => {
  const { id, formik } = props;
  const [, actions] = useAggregateContext();

  React.useEffect(() => {
    actions.createFormState(id);
    actions.setFormState(id, formik);
    return () => actions.clearFormState(id);
  }, [formik]);

  return null;
};

export default connect<AggregateEffectProps>(AggregateEffect);

Show the Warning Dialog when user navigate out.

// Dirty Form
import React from 'react';
import router from 'next/router';
import Dialog from '@material-ui/core/Dialog';
import DialogContent from '@material-ui/core/DialogContent';
import DialogTitle from '@material-ui/core/DialogTitle';
import DialogActions from '@material-ui/core/DialogActions';
import Button from '@material-ui/core/Button';
import { useAggregateContext } from '../AggregateRoot';

export interface DirtyFormDialogProps {};

const DirtyFormDialog: React.ComponentType<DirtyFormDialogProps> = () => {
  const [open, setOpen] = React.useState(false);
  const pendingUrl = React.useRef<string | null>(null);
  const confirm = React.useRef<boolean>(false);

  const [formStates] = useAggregateContext();

  const handleBeforePopState = React.useCallback((url: string) => {
    const hasDirtyForm = !!Object.keys(formStates).find(formId => formStates[formId].dirty);
    if (hasDirtyForm) {
      pendingUrl.current = url;
      setOpen(true);
    } else {
      confirm.current = true;
      setOpen(false);
    }
    return !hasDirtyForm;
  }, [formStates]);

  const handleRouteChange = React.useCallback((url: string) => {
    const hasDirtyForm = !confirm.current || !handleBeforePopState(url);
    if (hasDirtyForm) {
      throw new Error('Abort route change');
    }
  }, [formStates]);

  const handleRouteComplete = React.useCallback(() => {
    pendingUrl.current = null;
    confirm.current = false;
    setOpen(false);
  }, []);

  const handleCancel = React.useCallback(() => {
    pendingUrl.current = null;
    confirm.current = false;
    setOpen(false);
  }, []);

  const handleContinue = React.useCallback(() => {
    if (pendingUrl.current != null) {
      const targetUrl = pendingUrl.current;
      pendingUrl.current = null;
      confirm.current = true;
      router.push(targetUrl);
    }
  }, [pendingUrl.current]);

  React.useEffect(() => {
    router.beforePopState(handleBeforePopState);
    return () => router.beforePopState(() => true);
  }), [formStates];

  React.useEffect(() => {
    router.events.on('routeChangeStart', handleRouteChange);
    return () => router.events.off('routeChangeStart', handleRouteChange);
  }, [formStates]);

  React.useEffect(() => {
    router.events.on('routeChangeComplete', handleRouteComplete);
    router.events.on('routeChangeError', handleRouteComplete);
    return () => {
      router.events.off('routeChangeComplete', handleRouteComplete);
      router.events.off('routeChangeError', handleRouteComplete);
    };
  }, []);

  React.useEffect(() => {
    router.events.on('routeChangeError', () => console.log('Error'));
    return () => router.events.off('routeChangeStart', handleRouteChange);
  }, [formStates]);

  return (
    <Dialog open={open}>
      <DialogTitle>Warning</DialogTitle>
      <DialogContent>
        You have unsaved changes. If you leave before saving, your changes will be lost. Do you want to continue?
      </DialogContent>
      <DialogActions>
        <Button color="primary" onClick={handleCancel}>
          Cancel
        </Button>
        <Button color="primary" onClick={handleContinue}>
          Continue
        </Button>
      </DialogActions>
    </Dialog>
  );
};

export default DirtyFormDialog;

Why do you need this aggregate context thing? Just put the dialog under formik context so that it has access to it. You can then trigger a submit or whatever you need without copying formik state elsewhere

There are lots of way user could navigate to other pages (i.e, the url has been changed). Those buttons or anchor links may not be part of component inside <Form>.

I am not trying to prompt the dialog when the form becomes dirty, but prompt when url has been changed and there is any dirty form in current page haven't been submitted.

If you're using react-router-dom the Prompt component does that. It only shows when the when property is truthy AND the user is navigating away. So you can just use it inside your Form component.

@felipeleusin I am using Next.js. Not sure if there is equivalent component.

Haven't used react-router for a long time.
So the better way to achieve this function is writing a custom component like what the Prompt component did in react-router-dom?

It makes sense and I will close this issue.

I don't have familiarity with Next but you should be able to do something with window.onbeforeunload. A quick google there is https://github.com/zeit/next.js#intercepting-popstate that might be of help

Here it is with react-router-dom's <Prompt>

https://codesandbox.io/s/formik-x-react-router-prompt-kvxt4

@felipeleusin @jaredpalmer
Thanks for help. I think I got the answer to get this feature done at right direction.

I believe I have a use-case for which the recommended options aren't sufficient. I have a modal with a couple tabs, each tab has some form fields. the save/cancel buttons are rendered in the modal nav menu. the modal has a prop onRequestClose, which is where I need to check if the form is dirty. i'm not finding a way to share that information with that method, other than placing the <Formik> as an ancestor to the modal, which does not work because of how the modal handles rendering it's overlay, menu, and content. the react-router Prompt is not acceptable for us because we are rendering (another) modal with the messaging about discarding unsaved changes.

https://share.squarespace.com/ApuAdlb7

<Switch>
  <Redirect exact from="/calendars/default-limits" to="/calendars/default-limits/general" />
  <Route
    path="/calendars/default-limits"
    render={() => {
      // modal page is determined by router location
      // if pagePath is undefined, <Redirect /> above should handle it, defaulting to 'general'
      const pagePath = location.pathname.split('/default-limits/')[1];
      // kebab-case to title-case
      const page =
        pagePath &&
        pagePath
          .split('-')
          .map(c => c.charAt(0).toUpperCase() + c.substring(1))
          .join(' ');

      return (
        <Modal closeOnOverlayClicked onRequestClose={this.closeModal}>
          <Modal.Overlay />
          <Modal.Position position="center">
            <Formik
              enableReinitialize
              initialValues={{
                clientsCanNotScheduleLessThanXHoursBefore,
                clientsCanNotScheduleMoreThanXDaysBefore,
                clientsCanNotCancelOrRescheduleLessThanXHoursBefore,
                clientsCanReschedule,
                clientsCanCancel,
                clientsCanEditIntakeForms,
                clientsCanNotEditIntakeFormLessThanXHoursBeforeAppointment,
                lookBusyXPercent,
                reduceGaps,
                reduceGapsLessThanXHours,
                startTimesBasedOnAppointmentDuration,
                startTimesEveryXMinutes,
              }}
              onSubmit={values => this.updateSettings(values)}
              render={formikProps => (
                <form
                  onSubmit={e => {
                    e.preventDefault();
                    formikProps.handleSubmit();
                  }}
                >
                  <NavDialog page={page} className={classNames.navDialog}>
                    <NavDialog.Sidebar>
                      <NavDialog.Sidebar.Header>
                        <NavDialog.Sidebar.Header.Left>
                          {formikProps.dirty ? (
                            <>
                              <RosettaButton.Tertiary
                                type="submit"
                                style={{ marginRight: 10 }}
                                data-qa="default-limits-save"
                              >
                                <T project="acuity">Save</T>
                              </RosettaButton.Tertiary>
                              <RosettaButton.Tertiary onClick={this.closeModal}>
                                <T project="acuity">Cancel</T>
                              </RosettaButton.Tertiary>
                            </>
                          ) : (
                            <RosettaButton.Tertiary onClick={this.closeModal}>
                              <T project="acuity">Close</T>
                            </RosettaButton.Tertiary>
                          )}
                        </NavDialog.Sidebar.Header.Left>
                      </NavDialog.Sidebar.Header>

                      <NavDialog.Sidebar.Content>
                        <NavMenu onChange={this.handleChangePage} value={page}>
                          <NavText variant="title">Scheduling Limits</NavText>
                          <NavGroup>
                            {PAGES.map(p => (
                              <NavItem key={p} value={p}>
                                <NavText variant="subtitle">{PAGES_TRANSLATED[p]}</NavText>
                              </NavItem>
                            ))}
                          </NavGroup>
                        </NavMenu>
                      </NavDialog.Sidebar.Content>
                    </NavDialog.Sidebar>

                    <NavDialog.Body>
                      <NavDialog.Body.Header>
                        <NavDialog.Body.Header.Center>
                          <NavDialog.Body.Header.Title>
                            {PAGES_TRANSLATED[page]}
                          </NavDialog.Body.Header.Title>
                        </NavDialog.Body.Header.Center>
                      </NavDialog.Body.Header>

                      <Modal.TouchScroll>
                        <NavDialog.Body.Content>
                          <NavDialog.Body.Content.Title style={{ paddingLeft: '0px' }}>
                            {PAGES_TRANSLATED[page]}
                          </NavDialog.Body.Content.Title>
                          <Switch>
                            <Route
                              path="/calendars/default-limits/general"
                              render={() => (
                                <SchedulingLimitsGeneral
                                  formikProps={formikProps}
                                  settings={settings}
                                  updateSettings={this.updateSettings}
                                />
                              )}
                            />
                            <Route
                              path="/calendars/default-limits/start-time-intervals"
                              render={() => (
                                <SchedulingLimitsStartTimeIntervals
                                  formikProps={formikProps}
                                  settings={settings}
                                  updateSettings={this.updateSettings}
                                />
                              )}
                            />
                            <Route
                              path="/calendars/default-limits/minimize-gaps"
                              render={() => (
                                <SchedulingLimitsMinimizeGaps
                                  formikProps={formikProps}
                                  settings={settings}
                                  updateSettings={this.updateSettings}
                                />
                              )}
                            />
                            <Route
                              path="/calendars/default-limits/look-busy"
                              render={() => (
                                <SchedulingLimitsLookBusy
                                  formikProps={formikProps}
                                  settings={settings}
                                  updateSettings={this.updateSettings}
                                />
                              )}
                            />
                          </Switch>
                        </NavDialog.Body.Content>
                      </Modal.TouchScroll>
                    </NavDialog.Body>
                  </NavDialog>
                </form>
              )}
            />
          </Modal.Position>
        </Modal>
      );
    }}
  />
</Switch>

it seems like if <Formik>'s validate function had the same params as validate does when using withFormik, (values, formikProps) instead of just (values), then I could use validate to define a function that set's state (or otherwise exports information about formikProps.dirty). but there doesn't appear to be a way to export that information. the other thing I'm considering is using the URL to export that, with a router param like ?isFormDirty=true, but that doesn't seem ideal.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

dearcodes picture dearcodes  路  3Comments

pmonty picture pmonty  路  3Comments

jordantrainor picture jordantrainor  路  3Comments

jaredpalmer picture jaredpalmer  路  3Comments

najisawas picture najisawas  路  3Comments