Web 1.0: What Web 2.0 isn't...but kind of is...
Static vs. interactive content
<tr>
<td align="middle" valign="top">
<br>
<br>
<a href="cmp/lineup/lineupframes.html">
<img src="img/p-lineup.gif" height="52" width="63" alt="The Lineup" border="0">
</a>
</td>
<td colspan="3" rowspan="2" align="right" valign="middle"></td>
<td align="right" valign="bottom"></td>
</tr>
But what about the full page reload, Alex? I see a flash and lose my scroll position every dang time.
The reason client rendering is still popular is that it is so much simpler than SSR. SSR can be a huge pain — dealing with hydration, environment differences, effects not running on the server, etc. For some apps, the DX of CSR outweighs the marginal UX benefits of SSR.
client side rendering is only simpler than server side rendering w/a JavaScript-first mindset
as soon as you let go of that & re-embrace hypermedia, SSR is *dramatically* simpler
i still have no idea what hydration means & absolutely refuse to learn on general principle
What needs to be done to make the REST architectural style clear on the notion that hypertext is a constraint? In other words, if the engine of application state (and hence the API) is not being driven by hypertext, then it cannot be RESTful and cannot be a REST API. Period. Is there some broken manual somewhere that needs to be fixed?"
–Roy Fielding, Creator of the term REST
htmx | Turbo | Unpoly | Categories |
---|---|---|---|
Installation | |||
Documentation | |||
Heartbeat | |||
Boost | |||
Form Validations | |||
Fragments | |||
Animations | |||
Streams | |||
Bonus | |||
Total |
javascript fatigue:
longing for a hypertext
already in hand
htmx gives you access to AJAX, CSS Transitions, WebSockets and Server Sent Events directly in HTML, using attributes, so you can build modern user interfaces with the simplicity and power of hypertext.
<head>
<script src="https://unpkg.com/htmx.org@1.9.0"></script>
</head>
// npm install htmx.org
// import 'htmx.org';
// window.htmx = require('htmx.org');
<body hx-boost="true">
<a href="/about" hx-boost="false">About</a>
<a href="/contact">Contact Us</a>
<a href="https://foo.bar">Foo</a>
</body>
HX-Boosted
header in the request<body hx-boost="true">
<form hx-include="#foo">
<fieldset>
<label for="name">Enter your name: </label>
<input type="text" name="name" id="name" required>
</fieldset>
<fieldset>
<label for="email">Enter your email: </label>
<input type="email" name="email" id="email" required>
</fieldset>
<button type="submit" hx-post="/contact-us">Submit</button>
</form>
<input id="foo" type="hidden" value="bar" />
</body>
GET
request, the inputs of the nearest enclosing form will be included.<form hx-post="/sign-up">
<div hx-target="this" hx-swap="outerHTML">
<label>Email Address</label>
<input name="email" hx-post="/contact/email" hx-indicator="#ind">
<img id="ind" src="/img/bars.svg" class="htmx-indicator"/>
</div>
<!-- ... -->
</form>
<!-- Server response with error -->
<div hx-target="this" hx-swap="outerHTML" class="error">
<label>Email Address</label>
<input name="email" hx-post="/contact/email" hx-indicator="#ind" value="test@foo.com">
<img id="ind" src="/img/bars.svg" class="htmx-indicator"/>
<div class='error-message'>That email is already taken. Try another one.</div>
</div>
POST
to /contact/email
for validation, when the changed
event occurserror
class and includes an error message element<input type="text" name="q"
hx-get="/search-results"
hx-trigger="keyup changed delay:500ms"
hx-target="#search-results"
placeholder="Search...">
<div id="search-results">...</div>
<style>
.smooth {transition: all 1s ease-in;}
.fade-me-out.htmx-swapping {
opacity: 0;
transition: opacity 1s ease-out;}
</style>
<div id="color-demo" class="smooth" style="color:red"
hx-get="/colors" hx-swap="outerHTML" hx-trigger="every 1s">
Color Swap Demo
</div>
<button class="fade-me-out"
hx-delete="/thing/42"
hx-confirm="Are you sure?"
hx-swap="outerHTML swap:1s">
Delete
</button>
<div hx-ws="connect:wss:/chatroom">
<div id="chat_room">
message 1,message 2
</div>
<form hx-ws="send:submit">
<input name="chat_message">
</form>
</div>
<script>
// Backend websocket server...
client.send(
`<div id="chat_room" hx-swap-oob="beforeend">,` +
the_msg.chat_message +
`</div>`);
</script>
<div hx-get="/news" hx-trigger="every 2s"></div>
<div hx-post="/mouse_entered" hx-trigger="mouseenter once">
[Here Mouse, Mouse!]
</div>
<div hx-get="/clicked" hx-trigger="click[ctrlKey&&shiftKey]">
Control+Shift Click Me
</div>
<div hx-get="/news" hx-trigger="every 2s"></div>
<div hx-get="/story/42" hx-trigger="revealed"></div>
They reduced the code base size by 67% (21,500 LOC to 7200 LOC)
They increased python code by 140% (500 LOC to 1200 LOC)
They reduced their total JS dependencies by 96% (255 to 9)
They reduced their web build time by 88% (40 seconds to 5)
First load time-to-interactive was reduced by 50-60% (from 2 to 6 seconds to 1 to 2 seconds)
Much larger data sets were possible when using htmx, because react simply couldn’t handle the data
Web application memory usage was reduced by 46% (75MB to 45MB)
The speed of a single-page web application without having to write any JavaScript.
<head>
<script type="module">
import hotwiredTurbo from 'https://cdn.skypack.dev/@hotwired/turbo';
</script>
</head>
// import * as Turbo from "@hotwired/turbo"
<head>
<script src="/application-cbd3cd4.js" data-turbo-track="reload"></script>
</head>
<body data-turbo="true">
<a href="/about" data-turbo="false">About</a>
<a href="/contact">Contact Us</a>
<a href="https://foo.bar">Foo</a>
<a href="/edit" data-turbo-action="replace">Edit</a>
<a href="/articles/54"
data-turbo-method="delete"
data-turbo-confirm="Are you sure you want to delete the article?">
Delete the article
</a>
</body>
<body data-turbo="true">
<form action="/contact-us">
<fieldset>
<label for="name">Enter your name: </label>
<input type="text" name="name" id="name" required>
</fieldset>
<fieldset>
<label for="email">Enter your email: </label>
<input type="email" name="email" id="email" required>
</fieldset>
<button type="submit">Submit</button>
</form>
</body>
<div id="navigation">Links targeting the entire page</div>
<turbo-frame id="message_1">
<h1>My message title</h1>
<p>My message content</p>
<a href="/messages/1/edit">Edit this message</a>
<form action="/messages" data-turbo-frame="navigation">
Submitting form will replace the navigation frame.
</form>
</turbo-frame>
<turbo-frame id="navigation"></turbo-frame>
<turbo-frame id="comments" src="/messages/1/comments" loading="lazy">
<div id="comment_1">One comment</div>
<div id="comment_2">Two comments</div>
</turbo-frame>
<turbo-stream action="append" target="messages">
<template>
<!-- render partial: "messages/message", collection: @messages -->
<div id="message_1">...</div>
</template>
</turbo-stream>
<turbo-stream action="prepend" target="messages">
<turbo-stream action="replace" target="message_1">
<turbo-stream action="update" targets=".sports_messages">
<turbo-stream action="remove" targets=".old_messages"></turbo-stream>
<turbo-stream action="before" target="current_step">
<turbo-stream action="after" target="current_step">
<!-- Content-Type: text/vnd.turbo-stream.html; charset=utf-8 -->
SPAs are a good fit for a certain class of UI. For us, that class is the exception, not the default.
<head>
<link rel="stylesheet" href="https://unpkg.com/unpoly@2.7.2/unpoly.css" />
<script src="https://unpkg.com/unpoly@2.7.2/unpoly.js"></script>
</head>
// npm install unpoly
<body>
<a href="/about" up-follow>About Us</a>
<a href="/contact" up-follow up-instant>Contact Us</a>
<a href="/faq" up-follow up-preload>FAQ</a>
</body>
<script>
up.link.config.followSelectors.push('a[href]');
up.link.config.instantSelectors.push('a[href]');
up.link.config.preloadSelectors.push('a[href]');
</script>
[up-follow]
attribute if the link has other attributes like [up-target]
<form method="post" action="/users" up-submit>
<div class="form-group">
<label>First Name</label>
<input type="text" class="form-control" name="firstName">
</div>
<!-- Other stuff... -->
<button type="submit">Submit</button>
</form>
<script>
up.form.config.submitSelectors.push(['form']);
</script>
[up-submit]
attribute if the link has other attributes like [up-target]
<form action="/users" up-submit>
<fieldset>
<label for="email" up-validate>E-mail</label>
<input type="text" id="email" name="email">
<!-- <div class="error">E-mail has already been taken!</div> -->
</fieldset>
<!-- Other stuff... -->
<button type="submit">Register</button>
</form>
POST /users HTTP/1.1
X-Up-Validate: email
X-Up-Target: fieldset:has(#email)
Content-Type: application/x-www-form-urlencoded
email=foo%40bar.com&password=secret
<div>
<a href="back.html" class="one" up-target=".one">
♥
</a>
<a href="front.html" class="two" up-target=".two">
Flip
</a>
</div>
<a href="/card/5" up-target=".content:before, .unread-count:maybe">...</a>
<article class="content">...</article>
// Transition new elements.
<a href="/users"
up-target=".list"
up-transition="cross-fade">
Show users
</a>
// Animate existing elements.
<a href="/users"
up-target=".list"
up-layer="new"
up-animation="move-from-top">
Show users
</a>
<div id="cart-items" up-hungry>
4 items
</div>
<div id="unread-count" up-poll
up-interval="10000"
up-source="/unread-count">
2 new messages
</div>
[id]
or a unique [class]
attribute.[up-poll]
elements are reloaded from the server periodically.// preview.html
<main>
<p>Story summary</p>
<a href="full.html" up-layer="new">
Read full story
</a>
</main>
// full.html
<main>
<h2>Full story</h2>
<p>Lorem ipsum dolor sit amet.</p>
</main>
[up-layer]s are isolated so you can't accidentally match a fragment in another layer.
SPAs are a good fit for a certain class of UI. For us, that class is the exception, not the default.
htmx | Turbo | Unpoly | Categories |
---|---|---|---|
1 | 2 | 3 | Installation |
1 | 3 | 2 | Documentation |
2 | 1 | 3 | Heartbeat |
2 | 1 | 3 | Boost |
1 | 3 | 2 | Form Validations |
1 | 2 | 3 | Fragments |
2 | 3 | 1 | Animations |
2 | 1 | 3 | Streams |
1 | 2 | 3 | Bonus |
13 | 18 | 23 | Total |
👎 |
😔 |
<html>
<head>
<meta charset="UTF-8">
<title>Lonely Globe Advisor</title>
</head>
<body>
<h1>Top Hotels in Rome</h1>
<ol>
<li>Villa Domus - Via Piacenza 9, Rome, Italy</li>
<li>Hotel Trivelli - Piazza Barberini 11, Rome, Italy</li>
</ol>
<script src="/upup.min.js"></script>
<script>
UpUp.start({
'content-url': 'hotels.html?user=bob',
'assets': ['css/bootstrap.min.css', 'css/offline.css']
});
</script>
</body>
</html>
<div _="on htmx:afterSettle log 'Settled!'"></div>
<div _="on load wait 5s then transition opacity to 0 then remove me">
Here is a temporary message!
</div>
<div hx-target="#content"
_="on htmx:beforeOnLoad take .active from .tabs for event.target">
<a class="tabs active" hx-get="/tabl1" >Tab 1</a>
<a class="tabs" hx-get="/tabl2">Tab 2</a>
<a class="tabs" hx-get="/tabl3">Tab 3</a>
</div>
<div id="content">Tab 1 Content</div>
<form class="sortable" hx-post="/items" hx-trigger="end">
<div class="htmx-indicator">Updating...</div>
<div><input type='hidden' name='item' value='1'/>Item 1</div>
<div><input type='hidden' name='item' value='2'/>Item 2</div>
<div><input type='hidden' name='item' value='2'/>Item 3</div>
</form>
<script>// hello_controller.js
import { Controller } from "stimulus";
export default class extends Controller {
static targets = [ "name", "output" ];
greet() {
this.outputTarget.textContent =
`Hello, ${this.nameTarget.value}!`
}
}</script>
<div data-controller="hello">
<input data-hello-target="name" type="text">
<button data-action="click->hello#greet">
Greet
</button>
<span data-hello-target="output"></span>
</div>