Skip to content

Dealing with Pending Changes

When a user begins editing data in your app — filling in a form, modifying metadata, or making any unsaved change — you need to ensure those changes are not silently discarded before they are persisted. The yuuvis shell provides PendingChangesService from @yuuvis/client-core to coordinate this across your application.

The service works as a task registry. Any component that has unsaved changes registers a pending task with a descriptive message. Other parts of the application — the router, list components, tab groups, dialogs — can then check whether any pending task exists before allowing the user to navigate away or switch context.

The service is a root-level singleton:

import { PendingChangesService } from '@yuuvis/client-core';

When a user attempts to leave while tasks are pending, a native browser confirmation dialog appears. Its text is built from the messages of all active pending tasks, concatenated and separated by ---. Duplicate messages are shown only once. If the user confirms they want to leave, all pending tasks are cleared and the action proceeds. If they cancel, the action is blocked.

A pending task has a simple lifecycle: it is started when the user begins editing and finished once the changes are either saved or explicitly discarded. Every task carries a message that is shown to the user if they attempt to leave before the task is finished.

Call startTask() when the user begins an edit. It returns a unique task ID that you store to finish the task later:

import { Component, inject } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { PendingChangesService } from '@yuuvis/client-core';
@Component({ ... })
export class MyEditorComponent {
readonly #pendingChanges = inject(PendingChangesService);
readonly #translate = inject(TranslateService);
#pendingTaskId?: string;
onFormChanged() {
// Only start a task if one is not already running
if (!this.#pendingTaskId) {
this.#pendingTaskId = this.#pendingChanges.startTask(
this.#translate.instant('my.editor.component.pending-changes.alert')
);
}
}
}

The message you provide is included in the confirmation dialog text shown to the user if they try to leave with unsaved changes. If no message is provided for a task, a default message is used.

Call finishTask() with the stored ID after changes are saved or discarded:

onSaveSuccess() {
if (this.#pendingTaskId) {
this.#pendingChanges.finishTask(this.#pendingTaskId);
this.#pendingTaskId = undefined;
}
}

To check whether any tasks are pending without showing a dialog, use hasPendingTask():

if (this.#pendingChanges.hasPendingTask()) {
// handle unsaved state
}

You can also check for a specific task by ID:

if (this.#pendingChanges.hasPendingTask(this.#pendingTaskId)) {
// this specific task is still open
}

To reactively observe all active tasks, subscribe to tasks$:

this.#pendingChanges.tasks$.subscribe(tasks => {
this.hasPending = tasks.length > 0;
});

PendingChangesGuard is a CanDeactivate guard that intercepts route changes while tasks are pending. The guard calls PendingChangesService.check(component), passing the routed component as context. When the component implements the PendingChangesComponent interface — which requires a single method hasPendingChanges(): boolean — the service calls that method to determine whether a confirmation dialog should be shown. If the interface is not implemented, the service falls back to checking its own task registry.

Add the guard to your route and implement PendingChangesComponent on the routed component:

import { Routes } from '@angular/router';
import { PendingChangesGuard } from '@yuuvis/client-core';
import { MyEditorComponent } from './my-editor.component';
export const routes: Routes = [
{
path: 'edit/:id',
component: MyEditorComponent,
canDeactivate: [PendingChangesGuard]
}
];
import { Component, inject } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { PendingChangesComponent, PendingChangesService } from '@yuuvis/client-core';
@Component({ ... })
export class MyEditorComponent implements PendingChangesComponent {
readonly #pendingChanges = inject(PendingChangesService);
readonly #translate = inject(TranslateService);
#pendingTaskId?: string;
// Required by PendingChangesComponent — the guard delegates to this method
hasPendingChanges(): boolean {
return !!this.#pendingTaskId;
}
onFormChanged() {
if (!this.#pendingTaskId) {
this.#pendingTaskId = this.#pendingChanges.startTask(
this.#translate.instant('my.editor.component.pending-changes.alert')
);
}
}
onSaveSuccess() {
if (this.#pendingTaskId) {
this.#pendingChanges.finishTask(this.#pendingTaskId);
this.#pendingTaskId = undefined;
}
}
}

When the user tries to navigate away, the guard calls check(component) on the service. If hasPendingChanges() returns true, a native browser confirmation dialog is shown. If the user confirms, all tasks are cleared and navigation proceeds. If they cancel, navigation is blocked.

In a list/detail layout, you also need to prevent the user from selecting a different list item while the current item has unsaved changes. The yuv-list, yuv-tile-list, and yuv-query-list components from @yuuvis/client-framework support a preventChangeUntil input: a function that returns true to block selection changes.

<yuv-list
[preventChangeUntil]="pendingChangesCheck"
(selectionChange)="onSelectionChange($event)"
></yuv-list>
import { PendingChangesService } from '@yuuvis/client-core';
@Component({ ... })
export class MyLayoutComponent {
readonly #pendingChanges = inject(PendingChangesService);
// check() returns true when there are pending changes and the user canceled
pendingChangesCheck = () => this.#pendingChanges.check();
}

The list blocks the selection change while pendingChangesCheck() returns true. When there are no pending tasks, check() returns false immediately and the selection proceeds without a dialog.

When your layout uses mat-tab-group and switching tabs destroys the active component — potentially losing unsaved form state — apply the yuvTabGuardDisable directive. It disables all tabs other than the currently active one for as long as there are pending tasks in the service.

<mat-tab-group yuvTabGuardDisable>
<mat-tab label="Details">
<ng-template matTabContent>
<app-detail-form></app-detail-form>
</ng-template>
</mat-tab>
<mat-tab label="History">
<ng-template matTabContent>
<app-history></app-history>
</ng-template>
</mat-tab>
</mat-tab-group>

The directive automatically checks PendingChangesService on every change detection cycle — no additional wiring is required. Import TabGuardDirective in the component that owns the template:

import { TabGuardDirective } from '@yuuvis/client-core';
import { MatTabsModule } from '@angular/material/tabs';
@Component({
imports: [TabGuardDirective, MatTabsModule],
...
})
export class MyLayoutComponent { }

To prevent a Material dialog from being closed via a backdrop click or the Escape key while there are unsaved changes, use DialogCloseGuard.

Unlike PendingChangesService, DialogCloseGuard is not a root-level service — each dialog instance needs its own guard, scoped to that dialog’s lifetime. Provide it explicitly in the component or dialog providers:

import { DialogCloseGuard } from '@yuuvis/client-core';
import { MatDialogRef } from '@angular/material/dialog';
@Component({
providers: [DialogCloseGuard],
...
})
export class MyDialogComponent {
readonly #dialogCloseGuard = inject(DialogCloseGuard);
readonly #dialogRef = inject(MatDialogRef);
constructor() {
// Automatically prevents closing while PendingChangesService has active tasks.
// The dialog closes once the user confirms discarding changes.
this.#dialogCloseGuard.pendingChangesDialogCanClose(this.#dialogRef).subscribe();
}
}

For custom close logic — for example, using your own confirmation UI — use preventCloseUntil(). It intercepts both backdrop clicks and Escape key presses, and closes the dialog when your function returns true:

this.#dialogCloseGuard.preventCloseUntil(this.#dialogRef, () => {
// Return true to allow closing, false to block
return !this.hasUnsavedChanges();
}).subscribe();