---
area: "Learn"
parent: "Loading state chapter"
url: "https://unpoly.com/learn/previews"
---
<nav aria-label="Unpoly docs">[All docs](https://unpoly.com/index.md) · [Loading state chapter](https://unpoly.com/learn/loading-state.md)</nav>

# Previews

Previews are temporary page changes shown while waiting for a network request. They signal that the app is working, or hint at how the page will ultimately look. When the request ends, Unpoly reverts the changes before rendering the response.

Because a preview appears immediately after the user's interaction, it makes your application feel more responsive. Unpoly's [feedback classes](https://unpoly.com/learn/feedback-classes.md), [placeholders](https://unpoly.com/learn/placeholders.md) and [disabling](https://unpoly.com/learn/disabling-forms.md) are built on previews. [Optimistic rendering](https://unpoly.com/learn/optimistic-rendering.md) is a preview that renders the expected result. Use [`up.preview()`](https://unpoly.com/api/up.preview.md) directly when these don't cover your case.

<nav aria-label="Contents">

#### Contents

- [Defining a preview](#basic-mutations)
- [Inspecting the preview context](#inspecting-the-preview-context)
- [How previews end](#ending)
- [Preview parameters](#parameters)
- [Multiple previews](#multiple)
- [Advanced mutations](#advanced-mutations)
- [Delaying previews](#delaying)
- [Using previews with caching](#using-previews-with-caching)

</nav>

## Defining a preview {#basic-mutations}

We want to show a spinner animation within a button while it is loading:

<figure>
<video src="https://unpoly.com/images/api/button-spinner.mp4" aria-label="Video: A button showing a spinner while loading"></video>
<figcaption>

The user clicks a "Save changes" button. While the request is loading, the button turns darker and a spinner turns next to its label. When the request ends, the spinner is gone.

</figcaption>
</figure>

To achieve this effect, we define a _preview_ named `link-spinner` with [`up.preview()`](https://unpoly.com/api/up.preview.md):

```js
up.preview('link-spinner', function(preview) {
  let link = preview.origin
  preview.insert(link, '<img src="spinner.gif">')
})
```

The `preview` argument is an [`up.Preview`](https://unpoly.com/api/up.Preview.md) instance that offers many utilities to make temporary changes:

| Function | Effect |
| --- | --- |
| [`preview.insert()`](https://unpoly.com/api/up.Preview.prototype.insert.md) | Inserts an element. |
| [`preview.addClass()`](https://unpoly.com/api/up.Preview.prototype.addClass.md) | Adds a CSS class. |
| [`preview.removeClass()`](https://unpoly.com/api/up.Preview.prototype.removeClass.md) | Removes a CSS class. |
| [`preview.setAttrs()`](https://unpoly.com/api/up.Preview.prototype.setAttrs.md) | Changes an element's attributes. |
| [`preview.setStyle()`](https://unpoly.com/api/up.Preview.prototype.setStyle.md) | Sets inline CSS styles. |
| [`preview.hide()`](https://unpoly.com/api/up.Preview.prototype.hide.md) | Hides an element. |
| [`preview.show()`](https://unpoly.com/api/up.Preview.prototype.show.md) | Shows a hidden element. |
| [`preview.swapContent()`](https://unpoly.com/api/up.Preview.prototype.swapContent.md) | Replaces an element's children. |
| [`preview.showPlaceholder()`](https://unpoly.com/api/up.Preview.prototype.showPlaceholder.md) | Shows a [placeholder](https://unpoly.com/learn/placeholders.md). |
| [`preview.disable()`](https://unpoly.com/api/up.Preview.prototype.disable.md) | [Disables](https://unpoly.com/learn/disabling-forms.md) fields and buttons. |
| [`preview.openLayer()`](https://unpoly.com/api/up.Preview.prototype.openLayer.md) | Opens an overlay. |

All these changes are reverted automatically when the preview ends.

We can use the `link-spinner` preview in any link or form by setting an [`[up-preview]`](https://unpoly.com/api/up-preview.md) attribute:

```html
<a href="/edit" up-follow up-preview="link-spinner">Edit page</a>
```

When the link is followed, the preview appends the spinner element to the link label. When the server response is received, the spinner element is removed again.

### From JavaScript {#from-javascript}

From JavaScript you can pass the preview name as a `{ preview }` option:

```js
up.navigate({ url: '/edit', preview: 'link-spinner' })
```

If you don't want to define a named preview using [`up.preview()`](https://unpoly.com/api/up.preview.md), the JavaScript API also accepts an anonymous preview function:

```js
up.navigate({
  url: '/edit',
  preview: (preview) => preview.insert(preview.origin, '<img src="spinner.gif">')
})
```

## Inspecting the preview context {#inspecting-the-preview-context}

The [`up.Preview`](https://unpoly.com/api/up.Preview.md) object provides getters to learn more about the current render pass:

```js
up.preview('my-preview', function(preview) {
  preview.origin         // The link or form that caused the request
  preview.fragment       // The fragment being updated
  preview.layer          // The layer being updated
  preview.request        // The request we're waiting for
  preview.params         // Requested form params
  preview.renderOptions  // All options for this render pass
  preview.revalidating   // Whether we're previewing a cache revalidation
})
```

In particular `preview.params` is helpful to [preview the effects of a form submission](https://unpoly.com/learn/optimistic-rendering.md#previewing-form-submissions).

## How previews end {#ending}

A preview ends when its associated request ends for _any_ reason. Reasons include:

- The server responds with new HTML.
- The server [renders an error code](https://unpoly.com/learn/error-responses.md#fail-options).
- The request encounters a [fatal error](https://unpoly.com/learn/network-issues.md), like a timeout or loss of network connectivity.
- The server [updates a different fragment](https://unpoly.com/api/X-Up-Target.md).
- The request is [aborted](https://unpoly.com/learn/aborting-requests.md), e.g. by a different link targeting the same fragment.

When the preview ends, all its page changes are reverted before the server response is processed. This ensures a consistent screen state in cases when a request is aborted, or when we end up updating a different fragment.

When a preview takes focus away from an element, Unpoly focuses it again after the preview is reverted, unless the user has focused another element in the meantime.

To manually end a preview, [abort](https://unpoly.com/learn/aborting-requests.md) its associated request.

To test whether a request has ended from an async [preview function](https://unpoly.com/api/up.preview.md), access the [`preview.ended`](https://unpoly.com/api/up.Preview.prototype.ended.md) property.

## Preview parameters {#parameters}

Preview functions can accept an options object as a second argument. This is useful to define multiple variations of a preview effect.

For example, the following preview accepts a `{ size }` option to show a spinner of varying size:

```js
up.preview('spinner', function(preview, { size = 50 }) {
  let spinner = up.element.createFromSelector('img', { src: 'spinner.gif', width: size })
  preview.insert(preview.fragment, spinner)
})
```

From HTML you can append the options to the [`[up-preview]`](https://unpoly.com/api/up-preview.md) attribute, after the preview name:

```html
<a href="/edit" up-follow up-preview="spinner { size: 100 }">Edit page</a>
```

### Passing options from JavaScript {#passing-options-from-javascript}

From JavaScript you can also pass a string containing the preview name and options:

```js
up.navigate({
  url: '/edit',
  preview: 'spinner { size: 100 }'
})
```

As an alternative, you can pass a function that calls [`preview.run()`](https://unpoly.com/api/up.Preview.prototype.run.md) with a preview name and options. This makes it easier to pass option values that already exist in your scope:

```js
let size = 100

up.navigate({
  url: '/edit',
  preview: (preview) => preview.run('spinner', { size })
})
```

## Multiple previews {#multiple}

A link or form can show multiple previews at once:

```html
<a href="/edit"
   up-follow
   up-preview="spinner, form-placeholder">
  Edit page
</a>
```

To pass [preview options](#parameters), append the options object after each preview name:

```html
<a href="/edit"
   up-follow
   up-preview="spinner { size: 20 }, form-placeholder { animation: 'pulse' }">
  Edit page
</a>
```

From JavaScript, pass multiple previews as either a comma-separated string or as an array:

```js
up.navigate({ url: '/edit', preview: 'spinner, form-placeholder' })
up.navigate({ url: '/edit', preview: ['spinner', 'form-placeholder'] })
```

You may also pass multiple anonymous preview functions as an array:

```js
let fn1 = (preview) => { ... }
let fn2 = (preview) => { ... }
up.navigate({ url: '/edit', preview: [fn1, fn2] })
```

## Advanced mutations {#advanced-mutations}

Instead of using an [`up.Preview`](https://unpoly.com/api/up.Preview.md) method, you can make arbitrary changes to the DOM.

For example, this preview uses the native browser API to add a `.loading` class to the `<body>` while a request is loading:

```js
up.preview('my-preview', function(preview) {
  // Only make a change when it is necessary.
  if (document.body.classList.contains('loading')) return

  document.body.classList.add('loading')

  // Undo our change when the preview ends.
  return () => document.body.classList.remove('loading')
})
```

In this manual approach we need to do additional work:

1. We must return a function that undoes our changes.
2. We must only undo changes that we caused. We may have multiple concurrent previews racing for the same changes, and we must not undo a change done by another preview.

> #### Tip
>
> All [`up.Preview`](https://unpoly.com/api/up.Preview.md) methods automatically undo their changes and work concurrently.

Another way to register an undo action is to register a callback with `preview.undo()`. This is especially useful when you're making multiple changes, and want to group each change and undo effect together:

```js
up.preview('my-preview', function(preview) {
  change1()
  preview.undo(() => undoChange1())

  change2()
  preview.undo(() => undoChange2())
})
```

### Prefer additive changes {#prefer-additive-changes}

While previews can change the DOM arbitrarily, we recommend to not remove the targeted fragment from the DOM. A detached fragment cannot be [aborted](https://unpoly.com/learn/aborting-requests.md).

```js
// ❌ Detached fragments cannot be aborted
up.preview('spinner', function(preview) {
  let spinner = up.element.createFromHTML('<img src="spinner.gif">')
  preview.fragment.replaceWith(spinner)

  return () => spinner.replaceWith(preview.fragment)
})
```

Instead of detaching a fragment, consider hiding it:

```js
// ✅ Hidden fragments can still be aborted
up.preview('spinner', function(preview) {
  let spinner = up.element.createFromHTML('<img src="spinner.gif">')
  preview.fragment.insertAdjacentElement('beforebegin', spinner)
  up.element.hide(preview.fragment)

  return () => {
    spinner.remove()
    up.element.show(preview.fragment)
  }
})
```

By using [`up.Preview`](https://unpoly.com/api/up.Preview.md) methods (which undo automatically), we can shorten the example considerably:

```js
up.preview('spinner', function(preview) {
  preview.insert(preview.fragment, 'beforebegin', '<img src="spinner.gif">')
  preview.hide(preview.fragment)
})
```

## Delaying previews {#delaying}

When your backend responds quickly, a preview will only be shown for a short time before it is reverted. To avoid a flash of preview state, you may want to run some previews for long-running requests only.

To do this, a preview function can set a timer using `setTimeout()` or [`up.util.timer()`](https://unpoly.com/api/up.util.timer.md). After the timer expires, the preview must [check if it is still running](https://unpoly.com/api/up.Preview.prototype.ended.md) before changing the page:

```js
up.preview('delayed-spinner', (preview) => {
  up.util.timer(1000, function() {
    if (!preview.ended) {
      preview.insert('<img src="spinner.gif">')
    }
  })
})
```

> #### Important
>
> When a preview changes the DOM after the preview has ended, these changes will not be reverted.

## Using previews with caching {#using-previews-with-caching}

Previews are _not_ shown when rendering content that is already [cached](https://unpoly.com/learn/caching.md). This avoids a flash when a preview is shown and immediately reverted.

After rendering cached content that is [expired](https://unpoly.com/learn/caching.md#expiration), Unpoly will usually [revalidate](https://unpoly.com/learn/caching.md#revalidation) the fragment by reloading it. To show a preview for the revalidating request, use the `[up-revalidate-preview]` attribute instead:

```html
<a href="/clients"
   up-follow
   up-preview="index-placeholder"
   up-revalidate-preview="spinner">
  Clients
</a>
```

The revalidation preview is run after the expired content has been rendered. Hence the preview can modify a DOM tree showing the destination screen (albeit with stale data).

To use the [`[up-preview]`](https://unpoly.com/api/up-preview.md) attribute for both the initial render pass and the revalidation request, set `[up-revalidate-preview]` to a [true value](https://unpoly.com/learn/attributes-and-options.md#boolean-attributes):

```html
<a href="/clients"
   up-follow
   up-preview="spinner"
   up-revalidate-preview="true">
  Clients
</a>
```

<nav aria-label="Reading order">

- [Previous: Placeholders](https://unpoly.com/learn/placeholders.md)
- [Next: Optimistic rendering](https://unpoly.com/learn/optimistic-rendering.md)

</nav>

