Let's say you have a react application where you take some input from the user and save it to your server through some API calls. For such use cases, it is a good thing to let the user know that changes might not be saved when they are trying to exit the page. In this blog post, we will see some strategies on implementing such behavior.
TLDR - The Application
Before we see the technique for the exit prompt, let us see, what our dummy app does. It is a very basic application with the following things:
- It shows a simple form to the user.
- User fills the form and clicks on the SUBMIT button.
- The form data is sent to a server through an API call.
The end goal of the application is to show a prompt to the user just before the exit of the page, if
- User has entered some value in the form, AND;
- The data is not saved to the server yet.
You can see the codesandbox demo above to see it in action. The application is written in TypeScript. Since the app is fairly small, don't worry too much about it. You should understand it just fine and maybe even start liking TypeScript and give it a try yourself (if not already!). Also I have written the same app in plain JavaScript throughout this blog post.
To keep our blog post short and to-the-point
- We will have only one input in the demo app. The concept should be similar no matter how many form controls you have.
- The form will not actually be sending the data to the server, rather we will mock it for the demo. We will discuss the code when we come to it.
- The save to server procedure is always asynchronous, we will observe same behavior in our demo app.
Let's now dive into the different part of the app and see what it does.
Writing the basic application code
Without worrying about the exit prompt, the base code for the application would look something like this.
1import { useEffect, useState } from 'react';23function saveToApi(hobby) {4 return new Promise(resolve => {5 setTimeout(() => {6 console.log(hobby);7 resolve();8 }, 1000);9 });10}1112export default function App() {13 // our application form state, we could use some libraries here, like Formik14 // or react hook form15 const [value, setValue] = useState('');1617 return (18 <div className="App">19 <form20 onSubmit={e => {21 e.preventDefault();22 // form submit logic goes here23 // Call our async function to save to the API24 saveToApi(value).then(() => {25 // save is done, so we can the value26 setValue('');27 });28 }}29 >30 <label>31 <p>What is your hobby?</p>32 <input33 type="text"34 value={value}35 onChange={e => {36 setValue(e.target.value);37 }}38 />39 </label>40 <p>41 <button type="submit">SUBMIT</button>42 </p>43 </form>44 </div>45 );46}
Copied!
Notice the highlighted saveToApi
function. It is a mock implementation of a
save to api function. Usually, we would use something like fetch
to actually
make a call to our API endpoint. But it is not important for the scope of this
tutorial.
So what happens is pretty simple. First we have a local state for our form data.
1const [value, setValue] = useState('');
Copied!
Of course, we could've used something like Formik or React Hook Form if the form was more complex.
Then we have a simple form and an onSubmit
handler to call to our API.
1<form2 onSubmit={e => {3 e.preventDefault();4 // form submit logic goes here5 // Call our async function to save to the API6 saveToApi(value).then(() => {7 // save is done, so we can the value8 setValue('');9 });10 }}11>12 {/** ... */}13</form>
Copied!
When the form is modified by the user, or is being saved (clicking the SUBMIT button), sudden page exit could be a destructive action. Our goal is to prevent that.
The image above shows the native exit prompt of a browser. We intend to show it when the form is not saved or is being saved.
Observing form state in the application
So from the discussion before, we have already figured out when to show the prompt.
- If the form is modified by the user.
- If the form is being saved and not finished saving yet.
To track the events, we need a local state in our app. Let's call this
formState
.
1// A local state where we observe the formState.2// For our simple app, the three states 'unchaged', 'modified' and 'saving' are3// sufficient. Modify the logic according to your use-case.4const [formState, setFormState] = useState('unchanged');
Copied!
There should be three possible values of formState
, namely unchanged
,
modified
or saving
. This is sufficient for our use-case. If your application
is more complex or if you are already observing your form state, then adapt
accordingly.
Observing formState in form controls
Now our form begins with unchanged
state. It should change to modified
when
the user enters some value to the input. With this in mind, we modify the
onChange
handler of the input
.
1<input2 type="text"3 value={value}4 onChange={e => {5 if (e.target.value !== '') {6 setFormState('modified');7 } else {8 setFormState('unchanged');9 }10 setValue(e.target.value);11 }}12/>
Copied!
If the value is NOT empty, then formState
should be modified
, else it
should stay unchanged
.
Observing formState in form submit handler
Now we will need to modify the onSubmit
handler of the form
to observe the
formState
.
1<form2 onSubmit={e => {3 e.preventDefault();4 // form submit logic goes here5 // first set formState to saving6 setFormState('saving');7 // Now call our async function to save to the API8 saveToApi(value).then(() => {9 // save is done, so we can reset formState and value10 setValue('');11 setFormState('unchanged');12 });13 }}14>15 {/** ... */}16</form>
Copied!
The saveToApi
is an asynchronous operation like most real world use-cases. So
before beginning saveToApi
, we set formState
to saving
. Once the save
function is finished, we set it to unchanged
. In a more real world example,
you need to handle error cases too. But for now, let's keep it simple.
Modifying SUBMIT button based on formState
Now that we are properly observing all events for formState
, we can change how
the SUBMIT button behaves.
1<p>2 <button type="submit" disabled={formState === 'saving'}>3 {formState === 'saving' ? 'SUBMITTING' : 'SUBMIT'}4 </button>5</p>
Copied!
- We disable the submit button, when
formState
issaving
. This prevents multiple clicks during save. - When form is
saving
, we show a different textSubmitting
for a better user experience.
Adding the exit prompt logic
With our formState
properly in place, we need to hook into
onbeforeunload
of window
. There are a few things you should note:
- This event allows us to intercept the exit process of the browser window and present a prompt to the user.
- We cannot show custom messages or do anything else (like send a
fetch
request). If the user decides to actually exit the page, by clicking the confirm button, then there's nothing we can do about it.
The intent of this event handler is to show a prompt stating there's unsaved changes in the page. Then it is up to the user whether to exit or come back at the page to save the data.
Again, there's actually no way, to automatically save the data when user decides not to exit the page during the prompt. Your application code needs to take care of presenting an UI for actually saving the data.
The minimal code needed to intercept the exit of a page, looks like this.
1window.addEventListener('beforeunload', function (e) {2 // Cancel the event3 e.preventDefault(); // If you prevent default behavior in Mozilla Firefox prompt will always be shown4 // Chrome requires returnValue to be set5 e.returnValue = '';6});
Copied!
Now, you might've guessed, since this works by adding an event listener to
window
, we need to make use of react's
useEffect
hook . The code needed
for showing the exit prompt, while observing our previous formState
looks like
this:
1// A local state where we observe the formState.2// For our simple app, the three states 'unchaged', 'modified' and 'saving' are3// sufficient. Modify the logic according to your use-case.4const [formState, setFormState] = useState('unchanged');5// The effect where we show an exit prompt, but only if the formState is NOT6// unchanged. When the form is being saved, or is already modified by the user,7// sudden page exit could be a destructive action. Our goal is to prevent that.8useEffect(() => {9 // the handler for actually showing the prompt10 // https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload11 const handler = event => {12 event.preventDefault();13 event.returnValue = '';14 };15 // if the form is NOT unchanged, then set the onbeforeunload16 if (formState !== 'unchanged') {17 window.addEventListener('beforeunload', handler);18 // clean it up, if the dirty state changes19 return () => {20 window.removeEventListener('beforeunload', handler);21 };22 }23 // since this is not dirty, don't do anything24 return () => {};25}, [formState]);
Copied!
The logic is pretty simple. In the useEffect
if formState
is NOT
unchanged
, then we hook to beforeunload
to show the prompt.
If the formState
IS unchanged
, then we don't. The cleanup functions make
sure we remove our event listeners when formState
changes or when the
component unmounts.
Finished Application with Exit Prompt
With all the modifications, the finished application code will look like this.
1import { useEffect, useState } from "react";23function saveToApi(hobby: string) {4 return new Promise<void>((resolve) => {5 setTimeout(() => {6 console.log(hobby);7 resolve();8 }, 1000);9 });10}1112export default function App() {13 // our application form state, we could use some libraries here, like Formik14 // or react hook form15 const [value, setValue] = useState<string>("");1617 // A local state where we observe the formState.18 // For our simple app, the three states 'unchaged', 'modified' and 'saving' are19 // sufficient. Modify the logic according to your use-case.20 const [formState, setFormState] = useState<21 "unchanged" | "modified" | "saving"22 >("unchanged");2324 // The effect where we show an exit prompt, but only if the formState is NOT25 // unchanged. When the form is being saved, or is already modified by the user,26 // sudden page exit could be a destructive action. Our goal is to prevent that.27 useEffect(() => {28 // the handler for actually showing the prompt29 // https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload30 const handler = (event: BeforeUnloadEvent) => {31 event.preventDefault();32 event.returnValue = "";33 };3435 // if the form is NOT unchanged, then set the onbeforeunload36 if (formState !== "unchanged") {37 window.addEventListener("beforeunload", handler);38 // clean it up, if the dirty state changes39 return () => {40 window.removeEventListener("beforeunload", handler);41 };42 }43 // since this is not dirty, don't do anything44 return () => {};45 }, [formState]);46 return (47 <div className="App">48 <form49 onSubmit={(e) => {50 e.preventDefault();51 // form submit logic goes here52 // first set formState to saving53 setFormState("saving");54 // Now call our async function to save to the API55 saveToApi(value).then(() => {56 // save is done, so we can reset formState and value57 setValue("");58 setFormState("unchanged");59 });60 }}61 >62 <label>63 <p>What is your hobby?</p>64 <input65 type="text"66 value={value}67 onChange={(e) => {68 if (e.target.value !== "") {69 setFormState("modified");70 } else {71 setFormState("unchanged");72 }73 setValue(e.target.value);74 }}75 />76 </label>77 <p>78 <button type="submit" disabled={formState === "saving"}>79 {formState === "saving" ? "SUBMITTING" : "SUBMIT"}80 </button>81 </p>82 </form>83 </div>84 );85}
Copied!
You can also browse it at codesandbox .
So that was all about having a nice and logical exit prompt for unsaved changes in your react application. I hope it has helped you with your project. Feel free to take the discussion on twitter if you have any doubt or would like to ask me something.