Optimistic comments
Show the comment now, ask the server after.
A new comment goes into the resource with mutate() before the
server answers, marked as pending. When the fake server fails, the
comment is taken back out and a toast offers a retry.
Preview
Code
Save it as an .html file and open it in a browser. It loads
Signy and Iris CSS from the CDN, so there is nothing to install or build.
Show the full page
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Optimistic comments - Signy example</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/iriscss@1/dist/iris.min.css">
<script src="https://cdn.jsdelivr.net/npm/signy@1/dist/signy.global.min.js"></script>
</head>
<body>
<main class="container" id="app"></main>
<script>
const { Component, Signal, application, html, resource, For, Show, Portal, createRef } = Signy;
// A pretend server. Saving takes a second, and fails when the switch is on.
let serverId = 3;
const fetchComments = () =>
new Promise((resolve) => setTimeout(() => resolve([
{ id: 1, author: "Ada", text: "Signals make this so much simpler." },
{ id: 2, author: "Grace", text: "Agreed. No more re-renders to think about." },
]), 500));
const saveComment = (text, fail) =>
new Promise((resolve, reject) => setTimeout(() => {
if (fail) reject(new Error("The comment could not be saved."));
else resolve({ id: serverId++, author: "You", text });
}, 1000));
// An Iris CSS toast is a manual popover, so it is shown once it is in the page
class Toast extends Component {
box = createRef();
setup() {
this.onMount(() => this.box.current.showPopover());
}
render() {
return html`
<div ref=${this.box} class="toast danger" popover="manual" role="alert">
<p>${this.props.message}</p>
<button class="small" onClick=${this.props.onRetry}>Retry</button>
<button class="close" aria-label="Close" onClick=${this.props.onClose}></button>
</div>
`;
}
}
class Comments extends Component {
comments = resource(fetchComments);
fail = new Signal(false);
failure = new Signal(null);
draftId = 0;
// The comment shows at once, marked as pending, and leaves again if saving fails
async send(text) {
const draft = { id: `draft-${this.draftId++}`, author: "You", text, pending: true };
this.failure.value = null;
this.comments.mutate((list) => [...(list ?? []), draft]);
try {
const saved = await saveComment(text, this.fail.peek());
this.comments.mutate((list) => list.map((comment) => (comment.id === draft.id ? saved : comment)));
} catch (error) {
this.comments.mutate((list) => list.filter((comment) => comment.id !== draft.id));
this.failure.value = { message: error.message, text };
}
}
submit(event) {
event.preventDefault();
const form = event.currentTarget;
const text = form.elements.text.value.trim();
if (!text) return;
form.reset();
this.send(text);
}
render() {
return html`
<h1>Comments</h1>
${Show({
when: () => this.comments.loading && !this.comments.value,
children: html`<p aria-busy="true">Loading comments</p>`,
})}
<ul>
${For({
each: () => this.comments.value ?? [],
keyBy: "id",
children: (comment) => html`
<li style=${comment.pending ? "opacity: 0.6" : ""}>
<strong>${comment.author}</strong>: ${comment.text}
${comment.pending ? html`<small aria-busy="true">Sending</small>` : ""}
</li>
`,
})}
</ul>
<form onSubmit=${(e) => this.submit(e)}>
<label for="text">Your comment</label>
<textarea id="text" name="text" rows="2"></textarea>
<label>
<input type="checkbox" role="switch" onChange=${(e) => (this.fail.value = e.currentTarget.checked)} />
Make the server fail
</label>
<button class="primary">Post</button>
</form>
${Show({
when: () => this.failure.value,
children: (failure) => Portal({
children: html`
<${Toast}
message=${failure.message}
onRetry=${() => this.send(failure.text)}
onClose=${() => (this.failure.value = null)}
/>
`,
}),
})}
`;
}
}
application(Comments).mount("#app");
</script>
</body>
</html>