Navigation Home

hx-live: Client-Side State in HTML Attributes

React taught a generation of developers that local state means useState, side effects mean useEffect, and that nothing reaches a browser without a build pipeline. For single-page apps with complex routing and deep component trees, those tradeoffs make sense. Most server-rendered pages need far less: a live-updating ticker, a few reactive inputs, maybe a toggle. React is more machinery than those pages need.

hx-live is an extension for htmx 4. It adds attributes that bind an element's text, attributes, classes and styles to JavaScript expressions: :text sets the text, :hidden the hidden attribute, and so on. The state those expressions read lives in the page itself, in data-* attributes and form inputs. It needs no build step, and I use it in production on BudgetFlow.

The Pattern: State in data-* Attributes

BudgetFlow has an interactive article that compares your wealth to Elon Musk's in real time. A ticker counts his earnings per second, inputs let you adjust your own numbers, and computed values update instantly. This is the container element running it:

<ui-container
	class="blog-article-container"
	data-wealth-elon="716000000000"
	data-wealth-you="100000"
	data-income-you="6000"
	data-elapsed-seconds="0"
	hx-live="const openedAt = Date.now()
		while (this.isConnected) {
			data.elapsedSeconds = (Date.now() - openedAt) / 1000
			await forEvent(100)
		}"
	:data-elon-secondly-income="data.wealthElon * 0.05 / 31536000"
	:data-you-secondly-income="data.incomeYou * 12 / 31536000"
	:data-elon-earned-since-opened="data.elapsedSeconds * data.wealthElon * 0.05 / 31536000"
	:data-wealth-ratio="data.wealthYou / data.wealthElon"
>

The plain data-* attributes hold the inputs. The :data-* attributes compute derived values and write them back as data-* attributes. The hx-live attribute without a suffix runs code for side effects; here it updates the elapsed time every 100 milliseconds for as long as the element is on the page.

Inside every expression, data reads and writes the data-* attributes of the nearest element that has them. data.wealthElon reads data-wealth-elon and parses it as JSON, so it returns a number. You don't need a state object, a store or a component class: you can inspect the whole state in the browser's element inspector.

Reactive Bindings

The ticker that shows Elon's earnings since page load:

<h2 class="income-ticker">
	$<span :text="formatNumber(data.elonEarnedSinceOpened, 2)"></span>
</h2>

:text evaluates the expression and sets the element's text content. formatNumber is a small function in a <script> tag on the page. hx-live does not track which values an expression depends on. It reruns every expression after any input or change event, after any change to the page's elements or attributes, and after every htmx swap. When the ticker writes data-elapsed-seconds, that attribute change triggers the next run, and the text updates.

User inputs use htmx's own hx-on attribute, which runs code when an event fires:

<input
	type="text"
	value="100,000"
	hx-on:input="data.wealthYou = parseAmount(this.value); this.value = data.wealthYou.toLocaleString('en-US')"
/>

Type a number, the handler parses it, writes it to data-wealth-you on the container, and reformats the input. The attribute change reruns the expressions, so every value that depends on your net worth updates without a request to the server.

Derived Values

Derived values are bindings that write data-* attributes:

:data-elon-secondly-income="data.wealthElon * 0.05 / 31536000"

hx-live runs the expressions in document order. The container comes before the elements inside it, so the text bindings read the derived values from the same run. In the page, you reference them like any other attribute:

<span :text="formatNumber(data.incomeYou / data.elonSecondlyIncome, 2)"></span>

You skip useMemo, useCallback, and the dependency arrays that come with them. Because every expression reruns, there is no dependency list to get wrong.

Now Do It in React

Same feature, for comparison, in React:

import { useState, useEffect } from "react";

function ElonComparison() {
	const [wealthElon, setWealthElon] = useState(716000000000);
	const [wealthYou, setWealthYou] = useState(100000);
	const [incomeYou, setIncomeYou] = useState(6000);
	const [returnRate] = useState(0.05);
	const [elapsedSeconds, setElapsedSeconds] = useState(0);
	const [startTime] = useState(Date.now());

	useEffect(() => {
		const interval = setInterval(() => {
			setElapsedSeconds((Date.now() - startTime) / 1000);
		}, 100);
		return () => clearInterval(interval);
	}, [startTime]);

	const elonAnnualIncome = wealthElon * returnRate;
	const elonSecondlyIncome = elonAnnualIncome / (365 * 24 * 60 * 60);
	const youAnnualIncome = incomeYou * 12;

	const formatWithCommas = (value) =>
		value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");

	const handleInput = (e, setter) => {
		const raw = parseFloat(e.target.value.replace(/,/g, "") || 0);
		setter(raw);
		e.target.value = formatWithCommas(raw);
	};

	return (
		<div>
			<h2 className="income-ticker">
				${formatWithCommas(
					(elapsedSeconds * elonSecondlyIncome).toFixed(2)
				)}
			</h2>
			<input
				type="text"
				defaultValue="100,000"
				onInput={(e) => handleInput(e, setWealthYou)}
			/>
			<span>
				{(incomeYou / elonSecondlyIncome).toFixed(2)}
			</span>
		</div>
	);
}

export default ElonComparison;

The behavior is identical, but the React version carries more code. It has six useState calls, each producing a getter/setter pair, and a useEffect with a cleanup function for the interval. The JSX needs compilation, the module needs bundling, and the component needs mounting into a DOM node managed by React's reconciler.

The hx-live version adds attributes to server-rendered HTML. React demands ownership of the DOM tree it renders into.

Where hx-live Fits

hx-live is built for local, client-side interactivity that doesn't need a server round-trip. The kind of thing that would be a <script> tag in 2010 and a React component in 2020:

Lists work when the server renders the rows. The subscription calculator renders its rows in the template, adds new rows by copying a <template> element with hx-live's insert(), and computes the monthly total by reading the checked rows.

Where It Doesn't Fit

hx-live has no component model, which means no props, no context API and no component tree. All state is text in attributes, so objects pass through JSON on every read and write. hx-live also has no list rendering: a list that changes shape often has to be built with insert() or rendered by the server.

Every expression reruns on every change. That is fast for a page with a few dozen bindings; hx-live logs a warning when a run takes longer than 16 milliseconds. Complex multi-step wizards with shared state across steps, data grids with virtual scrolling, and drag-and-drop interfaces with undo/redo need a framework that tracks dependencies and manages component relationships.

htmx and hx-live

htmx handles server state and hx-live handles client state, with the same approach: both add attributes to existing HTML.

When a user creates a budget in BudgetFlow, htmx sends a POST, the server responds with HTML, and htmx swaps it into the page. When that same user plays with the Elon Musk comparison, hx-live updates the page from attributes on the page. Because hx-live reruns its expressions after every swap, bindings in HTML that arrives from the server start working as soon as it is inserted.

React tries to own both sides. It handles server data through useEffect and fetch calls (or React Query, or SWR, or Server Components), local state through useState, and the DOM through its virtual DOM reconciler. That's a lot of machinery for an app where the server already renders the HTML.

The split between htmx and hx-live matches the actual boundary: some state belongs to the server, some belongs to the client. Since both keep their state in the HTML, the server can render the starting state into the same attributes the client updates.

Start Your Financial Journey Today

Take control of your financial future with visual planning and dynamic budgeting. Sign up now and start managing your budget with confidence!