---
area: "Learn"
parent: "Forms chapter"
url: "https://unpoly.com/learn/unsaved-changes"
---
<nav aria-label="Unpoly docs">[All docs](https://unpoly.com/index.md) · [Forms chapter](https://unpoly.com/learn/forms.md)</nav>

# Preventing loss of unsaved changes

When a user has changed a form but not yet saved it, one click on the wrong link can throw away their work. A small compiler can ask the user before that happens, both on the page and in overlays.

The compiler on this page is deliberately simple. It covers the most common ways of leaving a form with minimal code, and is easy to extend. Each section below explains one part of it, names the cases that part leaves out, and shows how to cover them if your app needs it.

To ask before a single action instead, like deleting a record, see [Confirmation dialogs](https://unpoly.com/learn/confirmation-dialogs.md).

<nav aria-label="Contents">

#### Contents

- [The compiler](#compiler)
- [Tracking changes](#tracking)
- [Asking before following a link](#links)
- [Asking before closing an overlay](#overlays)
- [Asking before leaving the page](#leaving-the-page)

</nav>

## The compiler {#compiler}

Give every form that should ask before discarding changes a `[confirm-unsaved]` attribute. The form must be [submitted through Unpoly](https://unpoly.com/learn/submitting-forms.md), e.g. with an [`[up-submit]`](https://unpoly.com/api/up-submit.md) attribute:

```html
<form method="post" action="/projects/5" up-submit confirm-unsaved>
  <input type="text" name="name">
  <button type="submit">Save</button>
</form>
```

The attribute is not part of Unpoly. It is a hook for this [compiler](https://unpoly.com/learn/enhancing-elements.md):

```js
up.compiler('form[confirm-unsaved]', (form) => {
  let dirty = false
  let layer = up.layer.get(form)

  function confirmLeave(event) {
    if (dirty && !confirm('Discard unsaved changes?')) event.preventDefault()
  }

  return [
    // Any change makes the form dirty, saving it makes it clean.
    up.watch(form, () => dirty = true),
    up.on(form, 'up:form:submit', () => dirty = false),

    // Links in the form's layer, except links that open an overlay on top.
    layer.on('up:link:follow', 'a:not([up-layer~=new])', confirmLeave),

    // The overlay's own close controls: X button, Escape, backdrop.
    layer.on('up:layer:dismiss', (event) => {
      if ([':button', ':key', ':outside'].includes(event.value)) confirmLeave(event)
    }),

    // Reloading the page or closing the tab.
    up.on(window, 'beforeunload', (event) => { if (dirty) event.preventDefault() }),
  ]
})
```

Each function that registers a listener returns a function that removes it again. The compiler returns them all, so Unpoly [cleans up](https://unpoly.com/learn/enhancing-elements.md#destructor) when the form is removed.

## Tracking changes {#tracking}

The [compiler](#compiler) uses [`up.watch()`](https://unpoly.com/api/up.watch.md) to run a callback when the user changes a field in the form. The first change sets the `dirty` flag. The compiler doesn't compare the current values with the original ones, so a user who types a character and deletes it again is still asked. That keeps the code short, and asking once too often is cheaper than losing work.

When the form is submitted, it emits [`up:form:submit`](https://unpoly.com/api/up:form:submit.md), and the flag is cleared because the changes are on their way to the server. [Validating](https://unpoly.com/learn/validation.md) a field emits [`up:form:validate`](https://unpoly.com/api/up:form:validate.md) instead. Nothing was saved, and the validation only updates the field's [group](https://unpoly.com/learn/validation.md#form-groups), so the form stays dirty.

This leaves some cases out:

- A form that the server renders again starts out clean, since the compiler runs again for the new form. This happens after a failed save, or when a validation updates the entire form. A validation updates the entire form when a field has no group, so wrap your fields in an [`[up-form-group]`](https://unpoly.com/api/up-form-group.md), `<fieldset>` or `<label>`.
- Submitting clears the flag even when the changes don't reach the server, e.g. when the connection is lost, or when the user cancels an [`[up-confirm]`](https://unpoly.com/api/up-submit.md#up-confirm) dialog on the form.

To keep a form dirty when it was rendered from an [error response](https://unpoly.com/learn/error-responses.md), read the compiler's [`meta.ok`](https://unpoly.com/learn/enhancing-elements.md#meta) argument:

```js
up.compiler('form[confirm-unsaved]', (form, data, meta) => {
  let dirty = meta.ok === false
```

## Asking before following a link {#links}

Following a link emits a preventable [`up:link:follow`](https://unpoly.com/api/up:link:follow.md) event. The [compiler](#compiler) listens with [`up.Layer#on()`](https://unpoly.com/api/up.Layer.prototype.on.md), so it only hears links in the form's own layer.

Links that open a new overlay are skipped, since the form stays where it is, beneath the new overlay. The selector `[up-layer~=new]` matches every [`[up-layer]`](https://unpoly.com/learn/targeting-other-layers.md) value that contains the word `new`, like `new` or `new modal`. Other values like `swap`, `shatter`, `parent` or `root` still ask, because these links close the form's overlay or replace its content.

This leaves some cases out:

- A link that only updates another part of the page, like a sidebar, asks even though the form stays.
- A link in an overlay on top of the form doesn't ask, even when it updates the form's layer.
- Rendering from JavaScript with [`up.navigate()`](https://unpoly.com/api/up.navigate.md) or [`up.render()`](https://unpoly.com/api/up.render.md) doesn't ask, and neither does a [polling](https://unpoly.com/learn/polling.md) fragment that contains the form.
- Submitting another form in the same layer doesn't ask.

To also ask before another form in the same layer is submitted, add this listener to the array the compiler returns. It skips the form itself:

```js
layer.on('up:form:submit', (event) => {
  if (event.form !== form) confirmLeave(event)
}),
```

## Asking before closing an overlay {#overlays}

When the form is in an overlay, the [compiler](#compiler) asks before the user closes the overlay with its own controls. Each control dismisses the overlay with a [dismissal value](https://unpoly.com/learn/closing-overlays.md#dismissal-values): `":button"` for the `×` button, `":key"` for the Escape key and `":outside"` for a click on the backdrop. When the user closes the overlay this way, nothing has happened yet, so preventing the dismissal keeps everything as it was.

The compiler ignores other dismissal values. When the user follows a link in the overlay that targets a parent layer, they are asked when the link is followed. When the parent layer is updated, the overlay is [peeled](https://unpoly.com/learn/closing-overlays.md#peeling) with a value of `":peel"`. Listeners cannot prevent a peel, so the user isn't asked a second time.

This leaves some cases out:

- The Back button doesn't ask. Going back [closes all overlays](https://unpoly.com/learn/history-in-overlays.md#restoration) and restores an earlier page.
- An overlay that is closed by the server or by a [close condition](https://unpoly.com/learn/subinteractions.md#close-conditions) doesn't ask.
- Your own [`[up-dismiss]`](https://unpoly.com/api/up-dismiss.md) buttons don't ask.

To ask before your own cancel buttons close the overlay, give them a dismissal value and add it to the list:

```html
<button up-dismiss="'cancel'">Cancel</button>
```

```js
if ([':button', ':key', ':outside', 'cancel'].includes(event.value)) confirmLeave(event)
```

## Asking before leaving the page {#leaving-the-page}

Reloading the page, closing the browser tab or following a link that Unpoly doesn't handle all leave the page. For these, the browser emits a `beforeunload` event on the `window`. The [compiler](#compiler) prevents it while the form is dirty, which makes the browser ask the user whether to leave. Browsers show their own generic text and ignore any message you provide.

Browsers don't emit `beforeunload` reliably, especially on mobile devices, where a tab can be closed or discarded in the background without asking. If losing a draft would be costly, also save the form's values when the page is hidden, e.g. in a `pagehide` or `visibilitychange` listener.

<nav aria-label="Reading order">

- [Previous: Confirmation dialogs](https://unpoly.com/learn/confirmation-dialogs.md)
- [Next: Handling all forms](https://unpoly.com/learn/handling-all-forms.md)

</nav>

