Using htmx with Tauri

Patching XMLHttpRequest to use htmx as a frontend for Tauri

3 min read

I've been working with an app that's using htmx for more than 3 months now and I am really amazed how it simplified things with less scripting. I've also been experimenting with Tauri for a while and loved it for how flexible it is when it comes to choosing any frontend framework.

A lot of people have been asking and looking for solutions how to integrate htmx with Tauri, however, htmx requires a URL as it uses XMLHttpRequest internally which makes this a very interesting problem.

The solution I have in mind is creating "fake responses". With this, htmx thinks that it successfully reached the server and got a response. My attempt is similar to Python responses and respx which I used a lot for unit testing.

I published my solution in https://github.com/roniemartinez/tauri-plugin-htmx as an NPM library. The next section explains how it is done internally.

Patching XMLHttpRequest

Modifying XMLHttpRequest has been quite a challenge since a lot of properties are readonly. Luckily, this can be modified with Object.defineProperty(). Now, for the trick, we need to dispatch an event as XMLHttpRequest is asynchronous. I've tried to dispatch all events at first but noticed that "load" event is enough.

const {invoke} = window.__TAURI__.tauri;

const COMMAND_PREFIX = "command:";

const patchedSend = async function (params) {
    // Make readonly properties writable
    Object.defineProperty(this, "readyState", {writable: true})
    Object.defineProperty(this, "status", {writable: true})
    Object.defineProperty(this, "statusText", {writable: true})
    Object.defineProperty(this, "response", {writable: true})

    // Set response
    const query = new URLSearchParams(params);
    this.response = await invoke(this.command, Object.fromEntries(query));
    this.readyState = XMLHttpRequest.DONE;
    this.status = 200;
    this.statusText = "OK";

    // We only need load event to trigger a XHR response
    this.dispatchEvent(new ProgressEvent("load"));
};

window.addEventListener("DOMContentLoaded", () => {
    document.body.addEventListener('htmx:beforeSend', (event) => {
        const path = event.detail.requestConfig.path;
        if (path.startsWith(COMMAND_PREFIX)) {
            event.detail.xhr.command = path.slice(COMMAND_PREFIX.length);
            event.detail.xhr.send = patchedSend;
        }
    });
});

Creating a Tauri app with htmx as the frontend

Start a Tauri project using create-tauri-app and choosing "Vanilla" Javascript/Typescript as frontend.

$ cargo create-tauri-app                                                  
✔ Project name · tauri-htmx-ts
✔ Choose which language to use for your frontend · TypeScript / JavaScript - (pnpm, yarn, npm, bun)
✔ Choose your package manager · npm
✔ Choose your UI template · Vanilla
✔ Choose your UI flavor · TypeScript

Template created! To get started run:
cd tauri-htmx-ts
npm install
npm run tauri dev

Install HTMX

npm i htmx.org

Install tauri-plugin-htmx

npm i tauri-plugin-htmx

Modify index.html

To use htmx we need to modify the form inside index.html. There are a lot of ways to do it but I've made the structure similar to how it was before. Feel free to try other approaches in htmx.

Instead of using a URL in hx-post (or hx-get), use the command name prefixed with command: (e.g. command:greet).

<form class="row" id="greet-form">
    <input id="greet-input" placeholder="Enter a name..." />
    <button type="submit">Greet</button>
 </form>
original index.html
<div class="row">
    <input id="greet-input" name="name" placeholder="Enter a name..." />
    <button hx-post="command:greet" 
            hx-include="[name='name']" 
            hx-trigger="click" 
            hx-target="#greet-msg" 
            hx-swap="innerHTML">Greet</button>
</div>
new index.html

Modify main.ts or main.js

If you are using TypeScript as frontend, remove everything in main.ts and replacing it with just htmx and tauri-plugin-htmx imports.

import "htmx.org";
import "tauri-plugin-htmx";
main.ts

If you are using JavaScript as frontend, you need to copy the JS files from node_modules.

cp node_modules/htmx.org/dist/htmx.min.js src
cp node_modules/tauri-plugin-htmx/tauri-plugin-htmx.js src

Importing will be a little different in Javascript.

import "./htmx.min.js";
import "./tauri-plugin-htmx.js";
main.js

Run your Tauri app

You can now start your Tauri app and verify.

cargo tauri dev
Vanilla TypeScript
Vanilla JavaScript

Conclusion

Patching XMLHttpRequest is only one of the possible solutions for htmx. There are other suggested approaches like starting a separate server or a custom URI but we need to wait until these has been tried. Hopefully, the Tauri devs makes this baked into the Tauri so we don't need to patch things anymore. Or a much better solution is that Javascript functions will be called from htmx (see https://github.com/bigskysoftware/htmx/issues/103)

Read more articles like this in the future by buying me a coffee!

Buy me a coffeeBuy me a coffee