DEV Community

loading...
Cover image for Angular: "Unsaved Changes Will be Lost" Route Confirmation

Angular: "Unsaved Changes Will be Lost" Route Confirmation

Zack DeRose
Senior Engineer, loves Typescript, creating solid systems, chatting with folks, and trying to figure out ways to jump the carousel of tech!
Updated on ・9 min read

This article will walk through the use-case of creating an "Are you sure? Unsaved changes will be lost!" dialog in Angular that will keep a user on the current page, so they won't lose unsaved/unsubmitted changes to their forms by navigating away.

If you're looking for a quick fix for this use-case and source code is most helpful, here's a stackblitz of everything (with source code included!)

I'd encourage sticking around for the walkthrough though!

The Use Case

We have a component with its own route to let a user change their name. We've noticed that if a user navigates away from the component without saving, they're sometimes a bit confused why their name has not updated.

To fix this, when the user attempts to route away from the component, we'll show them a dialog saying "Are you sure you want to leave this page? Unsaved changes will be lost!", and then give them the option to either stay on this page or navigate away.

Our Tools

Puzzle pieces

For this example, we'll be using:

  • Angular Reactive Forms as our forms library
  • @ngrx/store as our state management library
  • Angular Material for our dialog service
  • Angular Router for routing (especially the canDeactivate() feature)

Using other tools here is fine (and may be dictated by the constraints of your own use-case)! The basic ideas article should hold through. Afterall, we're essentially taking this set of tools and combining them together like puzzle pieces.

As long as we have comparable tools/APIs, you should be able to swap out any of these tools for another!

Setting Up Our Global State!!

NgRx lifecyle

For our global state, we'll be using @ngrx/store (we won't be using @ngrx/effects, but we will discuss how it could be used to handle making http requests for us - in case we needed to send the user's name to the server).

Let's follow the diagram to create our implementation.

STORE

Looking at this problem, the user's name is state that belongs at the global level. The user's name is shared across the app, and show we'd like a single point of truth for the user's name - so that when we change it, that change fiction-less-ly propagates across our app.

So we'll set up some initial assets/typing to reflect this:

app.state.ts:

export const NAME_FEATURE_IDENTIFIER = "name";

export interface NameFeatureState {
  firstName: string;
  lastName: string;
}

export interface AppState {
  [NAME_FEATURE_IDENTIFIER]: NameFeatureState;
}
Enter fullscreen mode Exit fullscreen mode

^ Here we declare the interfaces for a "name feature" of our store. NAME_FEATURE_IDENTIFIER is the property name for our Store's state object. We'll export this, so we can use it when importing our StoreModule in our AppModule towards the end of this section.

The NameFeatureState interface then defines the single point of truth we'll use for our storing our name.

SELECTOR

Given we've defined our STORE, we can now build some selectors that will serve as 'queries' into the store's current contents.

If we think about how we'll use this data across our app:

  • We'll need to select the user's full name to tell them 'hello!' in our hello.component
  • We'll need to separately select the user's first and last name in order to pre-populate our name-form.component with the user's starting name.

So we'll add some selectors here to provide our app with the queries into these specific pieces of data:

app.state.ts:

export const nameFeatureSelector = createFeatureSelector<NameFeatureState>(
  NAME_FEATURE_IDENTIFIER
);
export const selectFirstName = createSelector(
  nameFeatureSelector,
  state => state.firstName
);
export const selectLastName = createSelector(
  nameFeatureSelector,
  state => state.lastName
);
export const selectFullName = createSelector(
  selectFirstName,
  selectLastName,
  (first, last) => `${first} ${last}`
);
Enter fullscreen mode Exit fullscreen mode

COMPONENT

Nothing to do here yet!!

When we get into both our hello.component and our name-form.component later though, we'll need to import our selectors to select() the pieces out of our state, and dispatch() actions when appropriate.

ACTION

Thinking about the relevant events in our use-case, the events that could exist in our application that would affect our name state is limited to our user submitting a new name via our Name Form. That action will need a payload of the form's current contents as well, which we'll include as props:

state.app.ts:

export const submitNameChange = createAction(
  "[Name Form] User Submit New Name",
  props<{ firstName: string; lastName: string }>()
);
Enter fullscreen mode Exit fullscreen mode

REDUCER

Our reducer is a function that takes an initial state and an action and returns a new state. We'll use @ngrx/store's [createReducer()](https://ngrx.io/api/store/createReducer#usage-notes) here to set our initial state (what our state will be when the app loads), and define a reducer function for a submitNameChange() action (essentially resetting the store contents to the submitted value).

app.state.ts

export const reducer = createReducer<NameFeatureState>(
  {
    firstName: "Zack",
    lastName: "DeRose"
  },
  on(submitNameChange, (_, newName) => newName)
);
Enter fullscreen mode Exit fullscreen mode

Now that we've completed the lifecycle, we can import the @ngrx/store's StoreModule to our AppModule:

app.module.ts:

@NgModule({
  imports: [
    /* ... */
    StoreModule.forRoot(
      { [NAME_FEATURE_IDENTIFIER]: reducer }
    )
  ],
  /* ... */
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

Managing State in our NameForm component!

Initially, let's set up to use our NameFormComponent without the "are you sure??" dialog just yet.

Thinking about the state here

  • We'll want to grab a snapshot of the current name state from our Store to populate our form
  • We'll want Angular Reactive Forms to manage the local state of our form (and touch it as little as possible).
  • We'll want to disabled the Submit button if there is no difference between the form's state and the Store's state. (This is definitely optional, but it's something I like to do as it helps with UX. We'll leverage the derived state here too of whether the form has changes for our logic of whether to show the dialog or not.)
  • Clicking submit should update our Store and navigate back to hello.

NOTE: We could definitely also use the built-in @angular/forms dirty property here to disable/enable our submit button, and take the Store completely out of the equation. I like the solution I describe better as it will keep in sync in case Store state changes while the user is on the page. Also, this solution will catch scenarios where the user types something in and then reverts back to the original input. dirty won't catch that, and the user could think they've updated their name, when in reality it's the same as when they started.

Initializing Form

Let's start with the first bullet here. I'm going to implement this with async/await and the ngOnInit() Angular lifecycle hook:

name-form.component.ts:

export class NameFormComponent implements OnInit {
  form: FormGroup;

  constructor(private _store: Store) {}

  async ngOnInit() {
    const firstName = await this._store.pipe(
      select(selectFirstName),
      take(1)
    ).toPromise();
    const lastName = await this._store.pipe(
      select(selectLastName),
      take(1)
    ).toPromise();
    this.form = new FormGroup({
      firstName: new FormControl(firstName),
      lastName: new FormControl(lastName)
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice that using the select() operator with the take() operator, we're able to convert it to a promise. When selecting from the Store, the Store emits with a Behavior strategy - meaning it will immediately emit as soon as subscribed to. By calling toPromise() on the observable, we are effectively subscribing to it, cause it to immediately emit. The take(1) operator then causes the observable to complete, which causes the newly wrapped promise to resolve with that first emission.

Our form is now all set and we can let Angular Reactive Forms manage that state for us without touching anything else.

Derived State: formHasChanges$

Using Store and our Reactive form, we now have an observable of both our 'global' state according to our Store, and our local state according to our form. These each are managing their respective state's source of truth.

We'll want to derive some state from these exposed observable streams to determine the derived state formHasChanges$.

To do this, we'll declare a public property on our class: formHasChanges: Observable<boolean>;

And to define this derived state, we'll reach for RxJS:

name-form.component.ts

  this.formHasChanges = combineLatest([
    this.form.valueChanges.pipe(startWith(this.form.value)),
    this._store.pipe(select(selectFirstName)),
    this._store.pipe(select(selectLastName))
  ]).pipe(
    map(([formValue, storeFirstName, storeLastName]) => {
      return formValue.firstName !== storeFirstName || formValue.lastName !== storeLastName
    })
  );
Enter fullscreen mode Exit fullscreen mode

Using combineLatest() from RxJS, we'll start listening immediately to the value of our form, as well as the value in our store, and whenever either changes, we'll compare the values and determine if the form has changes when compared to the store.

I find this is especially helpful (over formControl.dirty) for making your forms feel more reactive/smart/well built, as you can toggle your submit button disabled (and any other feedback to the end-user that they have [OR don't have!!] a change on their hands). The reactive nature of this code also means that if we submit some change to a backend, we can react to the store changing as well.

Alt Text

For now we'll use this Observable to disable/enable the Submit button, but we'll also tap into this same stream in our CanDeactivate guard.

CanDeactivate Guards

For the Angular Router piece of this puzzle, Angular has a built-in mechanism for preventing certain routing events - if you've spent some time in Angular, you're likely familiar with the concept of a guard.

Most of the time, these guards are in the context of preventing a user from accessing certain routes (for instance if a regular user attempts to route to a component that only an admin user should have access to). By setting the canActivate property of a given route to a CanActivate guard, we can define the logic for whether or not a user may access that route.

canDeactivate is very much the same thing, but in reverse. Rather than defining the logic for whether a user can get to a component/route, this flavor of guard defines logic for whether or not a user can leave a certain component/route!

Before creating our actual guard, let's actually take care of most of the logic within our class (as we'll need some of the component state to inform our decision):

  async canDeactivate(): Promise<boolean> {
    if (this._cancelClicked || this._submitClicked) {
      return true;
    }
    const formHasChanges = this.formHasChanges.pipe(take(1)).toPromise();
    if (!formHasChanges) {
      return true;
    }
    const dialogRef = this.dialog.open<
      ConfirmDialogComponent,
      undefined,
      { response: "leave page" | "stay on page" }
    >(ConfirmDialogComponent);
    const { response } = await dialogRef.afterClosed().toPromise();
    return response === "leave page";
  }
Enter fullscreen mode Exit fullscreen mode

Walking through this logic, we'll start with a check to see if the user is routing because they clicked 'cancel' or 'submit'. If so, we'll return true immediate to say 'Yes the user may leave.'

If we've made it past this check, we know that our user is trying to route away (maybe via clicking a navigation link for example). We'll want to check our formHasChanges Observable next to see if the user has left their form in a state where their form state doesn't match the store state. If there are no differences between the form and the store, there's no need to stop the user, so we'll return true at this point to again, let the user through!

If we've made to this point - we'll go ahead and open a dialog to inform our user that they have changes, and let them determine how to proceed.

Asking the user how to proceed

To proceed, we'll await the user's response, and if the user decides to leave page, we'll let them leave. (Note we're using the Angular Material Dialog API here, but it's likely most other dialog/modal Angular API's will have very similar APIs). Otherwise, we'll cancel the route event and return them to their form view.

That takes care of our logic, next we need to appropriately attach this logic to Angular's router.

To do this, we'll create a name-form.can-deactivate.guard that is pretty trivial - it simply references this logic we created in our component:

@Injectable()
export class NameFormCanDeactivateGuard
  implements CanDeactivate<NameFormComponent> {
  canDeactivate(component) {
    return component.canDeactivate();
  }
}
Enter fullscreen mode Exit fullscreen mode

And finally in our RouterModule import of the AppModule, we'll set in the canDeactivate value for our route:

@NgModule({
  imports: [
    /* ... */
    RouterModule.forRoot([
      { path: "", component: HelloComponent },
      {
        path: "form",
        component: NameFormComponent,
        canDeactivate: [NameFormCanDeactivateGuard]
      }
    ]),
    /* ... */
  ],
  providers: [NameFormCanDeactivateGuard],
  /* ... */
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

And voila!! We've created a proper Are you sure you want to leave? dialog!!

More Content By Zack

Blogs
YouTube
Twitch
Twitter
All Video Content Combined

Discussion (2)

Collapse
arielgueta profile image
Ariel Gueta
Collapse
zackderose profile image
Zack DeRose Author

The folks at ng-neat are awesome!!! 💯