Detect Unsaved Changes in Angular Forms
Many applications feature at least one form which allows users to edit previously submitted data. One example of many from our application is that users can create widgets and configure their settings. So, when they click on the settings button, we’ll present a form pre-populated with various settings which can be modified.
The product requirements for such forms are:
- If the user doesn’t change anything, meaning the form data is identical to the data received from the server, the save button should not be displayed.
- If the user changes the data and leave the page without saving, a pop-up window is displayed, warning that there is unsaved data on the page, in order to prevent accidental loss of data.
Here’s an illustration of what we want to achieve:
In this article, we’ll learn how to implement this functionality using observables data sources and Angular forms. Let the fun begin 😎
Creating the Scaffold
First, we need to create a mock data source, so we have something to work with. In our case, we’ll use a simple BehaviorSubject
. In real life, it’ll probably be an Observable
from Akita’s store, ngrx, or any other state management.
Next, we create a SettingsComponent
and initialize a FormGroup representation of our data:
We subscribe to the store$
observable; Whenever it emits, we call the form’s patchValue()
method to update the form’s values based on the data we get from the server.
In the submit()
method, we update the store based on the current form value. In real life, we’d check if the form was valid, make an HTTP request, update the store, and display a success notification to the user.
Creating the isDirty Operator
Our next step is to create an RxJS operator that combines two observables, get their values, perform a deep equality check and return a boolean indicating whether the form is dirty.
The first observable is the store$
which serves as our single source of truth. The second one is the form’s valueChanges
observable which emits the current form value upon every form changes. Here’s what we’re aiming for:
Let’s see the operator implementation:
Building your own operator is as simple as writing a function which takes a source observable as an input and returns an output stream.
In our case, we use the combineLatest()
observable to get both the current store and the form value — when one of them changes, we perform a deep-equal check using the fast-deep-equal library, and return the result.
We also set an initial value for the stream of false
, as the valueChanges
observable doesn’t emit immediately, and we don’t want the form to be considered dirty until it is.
🦊 Room for Improvement
Currently, if we subscribe to the isDirty$
observable multiple times, we’ll re-run the subscription function, which will cause the depth equality check to run again, something we’d like to avoid.
What we need is to always have a single subscription to the source (i.e. a Subject
), which shares the latest result with each subscriber.
The shareReplay
operator creates a ReplaySubject
, which is the only one subscribed to the source. Whenever we call subscribe()
, we’re always subscribed to this subject, which shares the latest value.
Great! We’re done with our first requirement, now let’s move on to the next one.
Handling In-App Navigation
The Angular Router provides the CanDeactivate
guard, where we can implement a canDeactivate
method that’ll be called upon any in-app navigation, and provide a reference to the component we’re navigating from.
This method should return a boolean
, an Observable<boolean>
, or a Promise<boolean>
. When we use a Promise
or an Observable
, the router will wait for that to resolve to truthy (navigate) or falsy (cancel navigation).
In our case, we will require each component that uses our dirtyCheck
operator to implement an isDirty$
property which we can subscribe to in our guard:
We subscribe to the isDirty$
observable. When the component isn’t dirty, we return of(true)
, which means we can navigate; otherwise, we open a modal component, and depending on the user’s response, we decide whether we should allow the navigation. Note that we also use take(1)
because Angular expects the first value from the observable to indicate the result.
The last step is to activate the DirtyCheckGuard
:
Handling Form Departure
At this point, we aren’t handling cases where users are leaving the application by closing the current tab or refreshing it.
We can tell when this occurs by listening to the window’s beforeunload
event. We’ll extend our operator and let it also handle this case:
When the beforeunload
event emits, we check if the component is dirty. If it is, we set the returnValue
to false, which will cause the browser to display its default confirmation dialog we’re all familiar with.
We don’t want memory leaks, so we clean the beforeunload
subscription by invoking the passed callback function from within the source’s finalize()
operator, which will be called when the source completes or errors.
Now we’ve fulfilled all the requirements, our product is happy 😀.
🚀 In Case You Missed It
Here are a few of my open source projects:
- Akita: State Management Tailored-Made for JS Applications
- Spectator: A Powerful Tool to Simplify Your Angular Tests
- Transloco: The Internationalization library Angular
- Forms Manger: The Foundation for Proper Form Management in Angular
- Cashew: A flexible and straightforward library that caches HTTP requests
Follow me on Medium or Twitter to read more about Angular, Akita and JS!