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, placeholders and
disabling are built on previews.
Optimistic rendering is a preview that renders the expected result.
Use up.preview() directly when these don't cover your case.
We want to show a spinner animation within a button while it is loading:
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.
To achieve this effect, we define a preview named link-spinner with up.preview():
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:
| Function | Effect |
|---|---|
preview.insert() |
Inserts an element. |
preview.addClass() |
Adds a CSS class. |
preview.removeClass() |
Removes a CSS class. |
preview.setAttrs() |
Changes an element's attributes. |
preview.setStyle() |
Sets inline CSS styles. |
preview.hide() |
Hides an element. |
preview.show() |
Shows a hidden element. |
preview.swapContent() |
Replaces an element's children. |
preview.showPlaceholder() |
Shows a placeholder. |
preview.disable() |
Disables fields and buttons. |
preview.openLayer() |
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] attribute:
<a href="/edit" up-follow up-preview="link-spinner">Edit page</a> <!-- mark: up-preview="link-spinner" -->
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 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">')
})
The up.Preview object provides getters to learn more about the current render pass:
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.
A preview ends when its associated request ends for any reason. Reasons include:
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 its associated request.
To test whether a request has ended from an async preview function,
access the preview.ended property.
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, { size = 50 }) { // mark: { 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] attribute, after the preview name:
<a href="/edit" up-follow up-preview="spinner { size: 100 }">Edit page</a> <!-- mark: spinner { size: 100 } -->
From JavaScript you can also pass a string containing the preview name and options:
up.navigate({
url: '/edit',
preview: 'spinner { size: 100 }'
})
As an alternative, you can 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('spinner', { size })
})
A link or form can show multiple previews at once:
<a href="/edit"
up-follow
up-preview="spinner, form-placeholder"> <!-- mark: 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] })
Instead of using an up.Preview 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:
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') // mark-line
})
In this manual approach we need to do additional work:
Tip
All
up.Previewmethods 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()) // mark: preview.undo
change2()
preview.undo(() => undoChange2()) // mark: preview.undo
})
While previews can change the DOM arbitrarily, we recommend to not remove the targeted fragment from the DOM. A detached fragment cannot be aborted.
// label: ❌ 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:
// label: ✅ 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 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)
})
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: 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.
Previews are not shown when rendering content that is already cached. 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: 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] 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: up-revalidate-preview="true" -->
Clients
</a>
Tip Your coding agent can search these docs locally with the Unpoly agent skill