Ditch heavy JS  and 💋 your frontend again with HTML over-the-wire

Alex Finnarn

@alexfinnarn

Whatever-end Pragmatist

CivicActions

https://slides.com/afinnarn/ditch-js

Ditch heavy JS  and 💋 your frontend again with HTML over-the-wire

  • The History
  • The Why?
  • The Rundown
  • HTMX
  • Turbo
  • Unpoly
  • The Roundup
  • When You Need More

History

Web 1.0: What Web 2.0 isn't...but kind of is...

Static vs. interactive content

History

History

<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>

History

History

History

History

History

The Why?

The Why?

The Why?

But what about the full page reload, Alex? I see a flash and lose my scroll position every dang time.

The Why?

The Why?

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. 

- A Twitter User

The Why?

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 

- Another Twitter User

The Why?

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

The Why?

The Rundown

htmx Turbo Unpoly Categories
Installation
Documentation
Heartbeat
Boost
Form Validations
Fragments
Animations
Streams
Bonus
Total

The Rundown

Installation

  • How easy is it to install
  • Asset weight

The Rundown

Documentation

  • How easy is it to install
  • How easy is it to maintain
  • Are there examples readily available for copy/paste
  • https://diataxis.fr/

The Rundown

Heartbeat

  • Important to gauge the long-term success
  • Can go by GitHub stars, number of downloads, issue queue management
  • At the end of the day, it's a feeling...based on my opinion

The Rundown

Boost

  • Allows for navigation without a full refresh
  • Updates the URL History
  • Can have a cache of previously visited pages
  • Includes forms as well as links

The Rundown

Instant Form Validations

  • Nice to let the user know something is wrong before submitting the whole form
  • SPA-JS usually has an advantage with more clientside validation
  • Built-in methods and examples for form field validations shows pragmaticism and forethought

The Rundown

Fragments

  • Breaks a page down into pieces
  • Targeted by an HTML attribute 
  • Can have different ways to swap elements
  • An easy way to lazy load parts of an app/page
  • Use different triggers to update fragments

The Rundown

Animations

  • FOUC is a major pain in Web 1.0 (although View Transitions API)
  • Need a way to make replacing content look decent
  • SPA-JS frameworks have animation support baked-in

The Rundown

Streams

  • Many apps now have real-time features: collaboration, broadcasting
  • Using WebSockets or Server Sent Events
  • Nice to declare the actions and relationships in HTML attributes

The Rundown

Bonus

  • Each framework has its quirks and superpowers
  • Highlight the biggest bonus I see for adopting each framework

The Rundown

Don't @ Me

  • Accessibility - much in docs about accessibility but too much to cover in this talk 
  • Complex Examples - Not appropriate when doing comparisons
  • Real-world usage - I can't use these at my day job, but maybe you could...

htmx

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.

htmx

Installation

<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');
  • Can use CDN but should install locally
  • 13.8 kb gzipped

htmx

Documentation

  • The overview is concise but thorough
  • The examples are great demos

htmx

Heartbeat

💓

  • 31,165 npm downloads weekly
  • 11,710 GitHub stars
  • 162 issues, 52 pull requests
  • last commit: today

 

 

htmx

Boost

<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>
  • Use [hx-boost] to set up boosting
  • Updates the URL History without reloading the whole page
  • The standard method is a GET for links and POST for forms
  • Sends an HX-Boosted header in the request

htmx

Forms

<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>
  • ​​​​​​If a non-GET request, the inputs of the nearest enclosing form will be included.
  • Can use hx-include for more targeting

htmx

Instant Form Validations

<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> 
  • The input will POST to /contact/email for validation, when the changed event occurs
  • The response has div with the error class and includes an error message element

htmx

Fragments

<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>
  • By default, AJAX requests are triggered by the “natural” event of an element
  • You can use the hx-trigger attribute to specify which event will cause the request.

htmx

Animations

<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>
  • CSS Transitions work if the id is preserved
  • CSS classes ".htmx-swapping" help with targeting
  • You can extend the swap/settle phase 

htmx

Streams

<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>
  • Moving to extensions in htmx 2.0
  • Can use "hx-ws" or "hx-sse" attributes
  • Can also dirty poll

htmx

Triggers + Essays

<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>

htmx

Memes

HTMX

  • A Real World React -> htmx Port

  • No reduction in the application’s user experience (UX)
  • 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)

Turbo

The speed of a single-page web application without having to write any JavaScript.

Turbo

Installation

  • Can use CDN but should install locally
  • 19.1 kb gzipped
<head>
  <script type="module">
    import hotwiredTurbo from 'https://cdn.skypack.dev/@hotwired/turbo';
  </script>
</head>

// import * as Turbo from "@hotwired/turbo"

Turbo

Documentation

  • Decent but lacks examples
  • You will likely use third-party sources

Turbo

  • 272,553 npm downloads weekly
  • 5,012 GitHub stars
  • 161 issues, 52 pull requests
  • last commit: 03/01/2023

Heartbeat

💓

Turbo - Drive

<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>
  • Navigate within a persistent process
  • Application visit: advance or replace
  • Restoration visit: restore

Boost

Turbo - Drive

<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>
  • Expects a 303 on success and 422 "Unprocessable Entity" on failure
  • Turbo will stay on the current URL rather than change it to the form action to avoid a reload GET

Forms

Turbo

Instant Form Validations

  • Return a 422 with the errors and form values
  • Respond with a Turbo Stream to target empty error elements

Turbo - Frames

<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>
  • Compartmentalize for discrete navigation and updates
  • "src" will lazy-load the frame's content
  • [data-turbo-frame] replaces other frame contents

Fragments

Turbo

  • Examples using JS
  • Might add/remove classes while swapping ...but bad docs

Animations

Turbo - Streams

<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 --> 
  • Seven different actions, but can add custom actions 
  • Easy to re-use templates for stream updates
  • Special content type for Turbo Streams

Streams

Turbo

  • Turbo Native for iOS manages a single WKWebView instance across multiple view controllers
  • Turbo Native for Android manages a single WebView instance across multiple Fragment destinations
  • Strada is...supposedly coming in 2023

Native

Turbo

Unpoly

SPAs are a good fit for a certain class of UI. For us, that class is the exception, not the default.

Unpoly

Installation

<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
  • Can use CDN but should install locally
  • Does have a CSS asset
  • 47.4 kb gzipped for JS + 1 kb CSS

Unpoly

Documentation

  • Tutorials, demos, and docs are alright
  • Ran into a bug that made me...full page refresh
  • A bit confusing, I was jumping around

Unpoly

  • 6,557 npm downloads weekly
  • 1,478 GitHub stars
  • 24 issues, 0 pull requests
  • last commit: yesterday

Heartbeat

💓

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>
  • Use [up-follow] to boost and add to URL History
  • You may omit the [up-follow] attribute if the link has other attributes like [up-target]
  • [up-instant|preload] for eager loading
  • You can programmatically follow all links

Boost

Unpoly

<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>
  • Use [up-submit] to boost forms
  • You may omit the [up-submit] attribute if the link has other attributes like [up-target]
  • You can programmatically follow all forms

Forms

Unpoly

<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>
  • Uses [up-validate] and header values when a field value changes
  • By default, Unpoly will only update the closest form group around the validating field.

Instant Form Validations

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

Unpoly

<div>
  <a href="back.html" class="one" up-target=".one">
    &hearts;
  </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>
  • [up-target] tells where to swap response content
  • You can have multiple targets and ways to place content
  • If the target might not be present, you can use a pseudo selector `:maybe`

Fragments

Unpoly

// 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>

Animations

Unpoly

<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>

  • No support for WebSockets or Serve Sent Events
  • [up-hungry] has to have an identifying attribute, like an [id] or a unique [class] attribute.
  • [up-poll] elements are reloaded from the server periodically.

Streams

Unpoly

// 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>
  • Fragments can be opened as modal dialogs, popup overlays, or drawers in infinitely stacked "layers".
  • [up-layer]s are isolated so you can't accidentally match a fragment in another layer.

Layers

Unpoly

Layers

Unpoly

The Roundup

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

When You Need More

  • Slow interaction feedback
  • Page loads destroy transient state (scroll positions, unsaved form values, focus)
  • Layered interactions are hard (modals, drop-downs, drawers)
  • Animation is complicated
  • Complex forms
  • Offline apps

👎

😔

When You Need More

UpUp

<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>

When You Need More

Hyperscript

<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>

When You Need More

Stimulus

<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>

When You Need More

  • Angular
  • Vue
  • React
  • Chex-mix Run
  • MemeQL
  • 1000's of friends on npm!!!

Abort

The Wrapup

  • You probably don't need as much JS in your app as you have currently
  • Hypermedia systems are your friend
  • htmx is my fav!
  • Turbo is great with Rails
  • Unpoly is...interesting
  • Stop calling any API that doesn't return Hypermedia RESTful 

Questions?

Ditch JS for HTML

By afinnarn

Ditch JS for HTML

  • 293