cri.dev
about posts rss

Using pure functions as views (with htmx and alpine.js)

Published on

In the past days I got my hands dirty with htmx and alpine.js

It was super refreshing and an smooth learning curve.

A lil steep and kind of depressing at the beginning, but once you get the hang of it, it was a breeze of fresh air in my development toolkit.

Here I want to get a bit into the details of how I used htmx and alpine.js to create a simple app, by focusing on the backend part.

Namely into how I used pure functions as views.

The idea is simple: instead of using a template engine (like nunjucks, jsx ssr, etc.), I just use a pure function that returns a string.

const view = (data) => {
  return `
    <div>
      <h1>${data.title}</h1>
      <p>${data.content}</p>
    </div>
  `
}

this can be used in your routes/controllers simply by calling the function, passing in data if needed, and return it as a response.

example with htmx and alpine

A view part of a web app I’m currently working on:

<div id="chats">
  <details>
    <summary>chats</summary>
    ${chats
      .map(chat => `
      <a 
        x-on:click="messageDisabled=true;pristineChat=true;viewingPreviousChat=true" 
        hx-get="/chats/${chat}" 
        hx-target="#messages" 
        href="/chats/${chat}">
          ${chat.replace('.json', '')}
      </a>
      `).join('<br>')}
  </details>
</div>

In this case, I’m returning parts of the html that will be used by htmx to update the DOM and by alpine to update the state and the UI.

Another example I like is the following:

<div id="messages" hx-swap="scroll:bottom">
  ${messagesView(messages)}
</div>
<input
  name="message"
  hx-post="/chat"
  hx-trigger="keyup[keyCode==13]"
  hx-target="#messages"
  hx-swap="beforeend scroll:bottom"
  hx-indicator="#loading-message"
  hx-on:htmx:before-request="this.disabled=true"
  hx-on:htmx:after-request="this.disabled=false;setTimeout(() => this.focus(), 20)"
  x-bind:disabled="messageDisabled"
  x-ref="message"
  x-model="message"
  x-on:keyup.enter="setTimeout(() => {message = '';pristineChat = false}, 10)"
  class="my-message" autofocus type="text" placeholder="your message">
<div style="position:fixed;bottom:3em;right:2em;" class="htmx-indicator" id="loading-message">
  <svg ...></svg>
</div>

htmx is used to send the message to the server, and update the DOM with the new messages.

it also disables the input and attaches a loading indicator to the input, so that the user knows that the message is being sent.

another cool part i find are the hx-trigger and hx-swap part.

hx-trigger is used to trigger the request when the user presses the enter key.

hx-swap in conjunction with beforeend scroll:bottom is used to append the new messages to the list of messages, and scroll to the bottom.

The POST /chat endpoint receives the user entered input and returns the LLM message in return.

views/messages.mjs

Here an mjs script that I use to render the messages:

export default function messagesView(messages = []) {
  return messages.map(message => {
    return `
    <div hx-transition class="${message.role}-message">
    ${message.content}
    </div>
    `;
  }).join('');
}

This is a simple function that takes an array of messages and returns a string of html.

And on the router.mjs file:

...
  if (req.url === '/chat' && req.method === 'GET') {
    res.statusCode = 200
    return res.end(messagesView(messages))
  }
...

Here, have a slice of pizza 🍕