Edit this page

API Previews

Previews are temporary page changes while waiting for a network request. They signal that the app is working, or provide clues for how the page will ultimately look.

Because previews immediately appear after a user interaction, their use increases the perceived responsiveness of your application.

Tip

Previews are an advanced technique to describe arbitrary loading state or optimistic UI. Unpoly also provides more accessible utilities for common use cases, implemented as previews internally. See loading state for an overview.

Overview

Previews are small functions that can be attached to a link, form or any programmatic render pass.

When the user interacts with a link or form, its preview function is invoked immediately. The function will usually mutate the DOM in a way the user gets a preview of the interaction effect. For example, if the user is deleting an item from a list, the preview function could hide that item visually.

When the request ends for any reason, all preview changes will be 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.

Unpoly provides utility functions to make temporary DOM mutations that automatically revert when the preview ends. For advanced cases you may also apply arbitrary mutations, as long as you revert them cleanly when the preview ends.

Mutating the DOM from a preview

For a simple example, we want to show a simple spinner animation within a button while it is loading:

To achieve this effect, we define a preview named link-spinner:

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

The preview argument is an up.Preview instance that offers many utilities to make temporary changes. For example, you can use it to insert or move elements, add classes or set attributes.

We can use the link-spinner preview in any link or form by setting an [up-preview] attribute:

<a href="/edit" up-follow up-preview="link-spinner">Edit page</a> <!-- mark-phrase "up-preview" -->

When the link is followed, the preview will append the spinner element to the link label. When the server response is received, the spinner element will be removed automatically.

From JavaScript

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

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

If you don't want to define a named preview using up.preview(), the JavaScript API also accepts an anonymous preview function:

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

Inspecting the preview context

The up.Preview object provides getters to learn more about the current render pass:

up.preview('my-preview', function(preview) {
  preview.target         // The target selector
  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.

Advanced mutations

Instead of using an up.Preview method you can use 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:

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 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:

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

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

Prefer additive changes

While previews can change the DOM arbitrary, we recommend to not remove the targeted fragment from the DOM. A detached fragment cannot be aborted.

up.preview('spinner', function(preview) {
  let spinner = up.element.createFromHTML('<img src="spinner.gif">')

  // ❌ Detached fragments cannot be aborted
  preview.fragment.replaceWith(spinner)

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

Instead of detaching a fragment, consider hiding it instead:

up.preview('spinner', function(preview) {
  let spinner = up.element.createFromHTML('<img src="spinner.gif">')
  preview.fragment.insertAdjacentElement('beforebegin', spinner)

  // ✅ Hidden fragments can still be aborted
  up.element.hide(preview.fragment)

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

By using up.Preview methods (which undo automatically), we can shorten the example considerably:

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

Preview 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:

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

From HTML you can append the options to the [up-preview] argument, after the preview name:

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

Passing options from JavaScript

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

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

As an alternative, you can also pass a function that calls preview.run() with a preview name and options. This makes it easier to pass option values that already exist in your scope:

let size = 100

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

How previews end

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

When the preview ends, all its page changes will be reverted before the server response is processed.

To manually end a preview, abort its associated request.

To test whether a request has ended from an async preview function, access the preview.ended property.

Using previews with caching

Previews are not shown when rendering cached content. This avoids a flash when a preview is shown and immediately reverted.

After rendering cached content that is expired, Unpoly will usually revalidate the fragment by reloading it. To show a preview for the revalidating request, use the [up-revalidate-preview] attribute instead:

<a href="/clients"
   up-follow
   up-preview="index-placeholder"
   up-revalidate-preview="spinner"> <!-- mark-phrase "up-revalidate-preview" -->
  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] attribute for both the initial render pass, and the revalidation request, set [up-revalidate-preview] to a true value:

<a href="/clients"
   up-follow
   up-preview="spinner"
   up-revalidate-preview="true"> <!-- mark-phrase "true" -->
  Clients
</a>

Delaying previews

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(). After the timer expires, the preview must check if it is still running before changing the page:

up.preview('delayed-spinner', (preview) => {
  up.util.timer(1000, function() {
    if (!preview.ended) { // mark-phrase "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.

Multiple previews

A change can show multiple previews at once:

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

To pass preview options, append the options object after each preview name:

<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:

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:

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