Optimistic rendering is a pattern where we update the page without waiting for the server to respond. When the server eventually does respond, the optimistic change is reverted and replaced by the server-confirmed content.
Optimistic rendering is an application of previews. Previews are temporary page changes that are reverted when a request ends.
Rendering optimistically can involve heavy DOM mutations to produce a screen state resembling the ultimate server response. Since this requires additional code, we recommend to use optimistic rendering for interactions where the duplication is low, or where the extra effort adds significant value for the user. Some suitable use cases include:
To limit the duplication of view logic, you may use templates. By embedding templates into your responses, the server stays in control of HTML rendering.
Note
Optimistic rendering is a recent feature in Unpoly, and inherently difficult in a server-driven approach. Expect more changes as we're looking for the best patterns.
For a demonstration of optimistic rendering in Unpoly, check out the Tasks tab in the official demo app. The entire TODO list is rendered optimistically. This includes the following interactions:
To see how it behaves under high latency, check the [×] Disable cache
and [×] Extra server delay
options in the bottom bar.
You will see that everything reacts instantly, despite an RTT
of ≈1200 ms (depending on your location). The X tasks left
indicator is not rendering optimistically on purpose,
so you see when an actual server response replaces the optimistic update:
The code for the demo app is available on GitHub. Although it is a implemented as a Rails application, the JavaScript code will be the same in any language or framework.
Let's take a closer look at how the demo app optimistically adds a task to the TODO list. This involves processing form data and rendering a major fragment on the client.
The HTML for the TODO list is structured like this:
<div id="tasks">
<!-- Form to add a new task -->
<form up-target="#tasks" up-preview="add-task"> <!-- mark-phrase "add-task" -->
<input type="text" name="text" required>
<button type="submit">Save</button>
</form>
<!-- List of existing tasks -->
<div class="task">Buy milk</div>
<div class="task">Buy toast</div>
<div class="task">Buy honey</div>
</div>
When the user submits a new task, we want to immediately add a new .task
element to the list
below the form. To do so the preview function add-task
can access the submitting form data
through preview.params
. The input value is then used to
construct a new .task
element and prepend it to the list:
up.preview('add-task', function(preview) {
let form = preview.origin.closest('form')
let text = preview.params.get('text')
let newTask = `<div class="task">${up.util.escapeHTML(text)}</div>`
preview.insert(form, 'afterend', newTask)
form.reset()
})
Because we're using the up.Preview#insert
function to prepend the new .task
element,
the element will automatically be removed when the preview ends.
This ensures a consistent screen state in cases where we end up not updating the entire #tasks
fragment,
e.g. when the form submission fails.
When a previewed form submission ends up failing due to a validation error, the preview will be reverted and the form is shown in an error state. Whenever possible, we want to avoid the jarring effect of a quickly changing screen state.
For simple constraints, consider native HTML validations that run on the client,
such as [required]
, [pattern]
or [maxlength]
.
For example, the example above uses the [required]
attribute to block form submission until
the task text has been filled in:
<form up-target="#tasks" up-preview="add-task">
<input type="text" name="text" required> <!-- mark-phrase "required" -->
<button type="submit">Save</button>
</form>
Some constraints can only be checked on the server, such as uniqueness or authorization. If a server-validated constraint is violated, the optimistic preview will be briefly visible, only to be replaced by the server-provided form state. For example, the TODO list from the demo app has a server-validated constraints that task items must be unique:
If a server-validated constraint violation is likely to occur, consider conveying previewed state as "pending", e.g. by reducing its opacity or using gray text.
Sometimes you can also use the [up-validate]
attribute to validate with the server while typing.
This increases the chance that a server-provided validation error is noticed before
the form is submitted.
When a preview function needs to update a major fragment, this can lead to a duplication of view logic. A HTML fragment that used to only be rendered by the server is now also found in the JavaScript.
By embedding templates into our responses, we can confine HTML rendering to the server. Since the server already knows how to render a task, we can now re-use that knowledge for optimistic rendering on the client.
In the example above, we would include a template with the rest of our markup:
<div id="tasks">
...
</div>
<script type="text/minimustache" id="task-template">
<div class="task">
{{text}}
</div>
</script>
Tip
This example uses the minimal
text/minimustache
templating function that you can copy into your project. See Dynamic templates for different strategies to build customizable templates.
By referring to the server-rendered template, we can now remove the HTML snippet from our JavaScript:
up.preview('add-task', function(preview) {
let form = preview.origin.closest('form')
let text = preview.params.get('text')
if (text) {
let newTask = up.template.clone('#task-template', { text }) // mark-line
preview.insert(form, 'afterend', newTask)
form.reset()
}
})