Comprehensive demonstration of optimistic updates with HTMX, featuring interpolation helpers, developer warnings, and enhanced error handling.
Quick start and key patterns for the optimistic HTMX extension. The demos below showcase each pattern.
<script src="https://unpkg.com/htmx.org@2"></script>
<script src="https://unpkg.com/hx-optimistic@1/hx-optimistic.min.js"></script>
<body hx-ext="optimistic">...</body>
<button
hx-post="/api/like"
hx-target="this"
hx-swap="outerHTML"
hx-ext="optimistic"
data-optimistic='{"snapshot":["textContent"],"values":{"textContent":"Saving..."},"delay":2000}'>
Like
</button>
<button
hx-post="/api/counter"
hx-target="closest .counter"
hx-swap="outerHTML"
hx-ext="optimistic"
data-optimistic='{"template":"<div class=\'counter hx-optimistic\'>...</div>","errorMessage":"Failed"}'>
+
</button>
{"errorMessage":"Could not save","delay":2000}
Click to like/unlike. The count increments when liked, decrements when unliked. 20% chance of simulated errors to demonstrate error handling.
hx-swap="outerHTML"
with wrapper container to prevent layout shifts.
<!-- Wrapper with min-height prevents layout shifts -->
<div style="min-height: 120px;">
<LikeButton liked={false} count={42} />
</div>
// Pre-calculate the optimistic state
const toggledLiked = !liked;
const toggledCount = toggledLiked ? count + 1 : count - 1;
const toggledText = toggledLiked ? `❤️ Liked! (${toggledCount})` : `🤍 Like (${toggledCount})`;
const optimisticConfig = {
snapshot: ["textContent", "className", "data-liked", "data-count"],
values: {
textContent: toggledText,
className: toggledClass,
"data-liked": toggledLiked.toString(),
"data-count": toggledCount.toString()
},
errorMessage: "Could not update like",
delay: 2000
};
<button
class={`btn ${liked ? 'btn-error' : 'btn-outline'}`}
style="min-width: 140px; width: 140px; white-space: nowrap;"
data-liked={liked}
data-count={count}
hx-post="/api/like"
hx-target="this"
hx-swap="outerHTML"
data-optimistic={JSON.stringify(optimisticConfig)}>
{liked ? '❤️' : '🤍'} {liked ? 'Liked!' : 'Like'} ({count})
</button>
Click to cycle through user status (Online → Away → Offline → Online). 20% chance of simulated errors to demonstrate error handling.
<!-- Wrapper with min-height prevents layout shifts -->
<div style="min-height: 200px;">
<StatusToggle status="online" username="Alex" />
</div>
<template id="status-optimistic-template">
<div class="status-card card bg-base-100 shadow-md border-2 border-base-300 hx-optimistic">
<div class="card-body p-4">
<!-- Mobile-friendly layout with consistent dimensions -->
<div class="flex flex-col gap-3">
<div class="flex items-center gap-3">
<div class="avatar placeholder">
<div class="bg-neutral text-neutral-content rounded-full w-12 h-12">
<span class="text-xl">${data:initials}</span>
</div>
</div>
<div class="flex-1 min-w-0">
<h3 class="font-semibold">${data:username}</h3>
<div class="flex items-center gap-2">
<span class="text-lg">${data:nextIcon}</span>
<span class="badge badge-${data:nextColor}">${data:nextText}</span>
</div>
</div>
</div>
<button class="btn btn-outline btn-sm w-full sm:w-auto">Change Status</button>
</div>
</div>
</div>
</template>
<button
class="btn btn-outline btn-sm"
hx-post="/api/status"
hx-target="closest .status-card"
hx-swap="outerHTML"
hx-ext="optimistic"
data-username={username}
data-initials={username.charAt(0).toUpperCase()}
data-next-icon={nextConfig.icon}
data-next-color={nextConfig.color}
data-next-text={nextConfig.text}
hx-vals={JSON.stringify({ username, currentStatus: status, newStatus: nextStatus })}
data-optimistic={JSON.stringify(optimisticConfig)}
>
Change Status
</button>
Post a comment and see it appear immediately with optimistic updates. 25% chance of simulated errors to demonstrate append-mode error handling.
${textarea}
---
const { comments = [] } = Astro.props;
const optimisticConfig = {
template: "#comment-optimistic-template",
errorTemplate: "#comment-error-template",
errorMode: "append", // Errors appear below form
delay: 3000,
target: ".comments-list",
swap: "beforeend" // New comments append to list
};
---
<form
class="comment-input-wrapper"
hx-post="/api/comments"
hx-target="closest .comment-system"
hx-swap="outerHTML"
data-optimistic={JSON.stringify(optimisticConfig)}
>
<textarea
name="comment"
class="w-full p-3 border border-gray-300 rounded-lg"
rows="3"
placeholder="Share your thoughts..."
required
></textarea>
<button type="submit" class="btn btn-primary">
Post Comment
</button>
</form>
<template id="comment-optimistic-template">
<div class="comment hx-optimistic border-l-4 border-blue-400 bg-blue-50 p-4 mb-4">
<div class="flex items-start gap-3">
<div class="avatar placeholder">
<div class="bg-blue-500 text-white rounded-full w-10 h-10">
<span class="text-sm font-bold">Y</span>
</div>
</div>
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<span class="font-semibold text-blue-700">You</span>
<span class="text-xs text-blue-600">posting...</span>
</div>
<!-- User's input appears here immediately -->
<p class="text-gray-800">${textarea}</p>
</div>
</div>
</div>
</template>
<!-- Errors appear below the form with errorMode: "append" -->
<template id="comment-error-template">
<div class="alert alert-error mt-2">
<span class="text-sm">❌ ${error}</span>
</div>
</template>
/* Prevent layout shift during optimistic updates */
.comment-form {
min-height: 200px;
transition: all 0.3s ease-out;
}
/* Smooth animation for new comments */
.comment.hx-optimistic {
animation: slideIn 0.3s ease-out;
border-left-color: #3b82f6 !important;
background-color: #dbeafe !important;
}
Click the text below to edit it in place. Changes are saved on blur or when you press Enter. 20% chance of simulated errors to demonstrate error handling.
Try editing this text:
<!-- Wrapper with min-height prevents layout shifts -->
<div style="min-height: 140px;">
<InlineEditor text="Click to edit this text" />
</div>
---
const { text } = Astro.props;
const optimisticConfig = {
snapshotContent: true,
template: "#inline-edit-optimistic",
errorTemplate: "#inline-edit-error",
delay: 2000,
};
---
<div
class="inline cursor-pointer px-2 py-1 rounded-md border-2 border-dashed
border-gray-300 bg-blue-50 hover:border-blue-500"
data-editable="true"
onclick="handleEditableClick(event)"
>
<span class="display-text">{text}</span>
<input
class="edit-input"
style="display: none"
type="text"
name="text"
value={text}
hx-patch="/api/edit"
hx-trigger="blur, keyup[key=='Enter'] consume"
hx-target="closest div"
hx-swap="outerHTML"
hx-ext="optimistic"
data-optimistic={JSON.stringify(optimisticConfig)}
/>
</div>
<!-- Optimistic template shows blue styling -->
<template id="inline-edit-optimistic">
<span class="text-blue-600 font-semibold bg-blue-100">
${this.value}
</span>
</template>
<!-- Error template shows error indicator -->
<template id="inline-edit-error">
<span>
${this.value} <span class="text-red-600 ml-1">❌</span>
</span>
</template>
Rate a product and see rich optimistic updates from attributes and content. 25% chance of simulated errors to demonstrate error handling.
<!-- Wrapper with min-height prevents layout shifts -->
<div style="min-height: 360px;">
<ProductRating productId="wireless-headphones-pro" />
</div>
---
// Create optimistic config for each button
const createOptimisticConfig = (rating: number) => ({
template: `#rating-optimistic-${rating}`,
errorTemplate: "#rating-error-template",
delay: 2000,
});
---
<!-- Generate a template for each rating (1-5) -->
{[1, 2, 3, 4, 5].map((rating) => (
<template id={`rating-optimistic-${rating}`}>
<!-- Template shows the selected rating as active -->
...
</template>
))}
{[1, 2, 3, 4, 5].map((rating) => (
<button
class="btn btn-outline btn-sm hover:btn-warning"
data-product-id={productId}
data-rating={rating}
hx-post="/api/rating"
hx-target={`#rating-${productId}`}
hx-swap="outerHTML"
hx-vals={JSON.stringify({ rating, productId })}
data-optimistic={JSON.stringify(createOptimisticConfig(rating))}
>
{rating}⭐
</button>
))}
<template id="rating-optimistic-3">
<div class="rating-section hx-optimistic">
<h5>Rate this product:</h5>
<div class="flex gap-2">
<!-- Button 3 is shown as active/selected -->
<button class="btn btn-outline">1⭐</button>
<button class="btn btn-outline">2⭐</button>
<button class="btn btn-warning btn-active" disabled>3⭐</button>
<button class="btn btn-outline">4⭐</button>
<button class="btn btn-outline">5⭐</button>
</div>
<div class="alert alert-info mt-4">
<span>Saving your 3 star rating...</span>
</div>
</div>
</template>
/* Active state with pulse animation */
.btn.btn-active {
background-color: #fbbf24 !important;
border-color: #f59e0b !important;
transform: scale(1.1);
box-shadow: 0 0 0 3px rgba(251, 191, 36, 0.3);
animation: pulse 0.5s ease-in-out;
}
@keyframes pulse {
0%, 100% { transform: scale(1.1); }
50% { transform: scale(1.15); }
}
Open your browser's Developer Console and click these buttons to see helpful warning messages for unsupported interpolation patterns.
[hx-optimistic] Unresolved interpolation pattern: ${this.querySelector('.nonexistent')} Supported patterns: ${this.value} - element value ${this.textContent} - element text content ${this.dataset.key} - data attribute ${data:key} - data attribute shorthand ${attr:name} - any attribute ${Math.max(0, count)} - arithmetic with count ${status}, ${statusText}, ${error} - error context See documentation for details.
Discussion
This is really helpful, thanks for sharing!
I had the same question. Great explanation!
Add a comment