Okay, I did it. This probably breaks every rule or principle that htmx has, and my only defense is that it was fun.
Demo video!
IMPORTANT:
This was created mainly as a joke and was never meant for production. If you want htmx, use htmx. It is a real project; this is a questionable React experiment.
After hearing about htmx for months, I finally tried it. In a sea of complexity and maximalism, HTML with a few extra powers felt almost magical.
While reading about it, the React part of my brain (ouch) kept wondering whether its ideas could sit on top of Server Components and Server Actions. Strangely enough, they seemed compatible in my head. I get scared about my own thoughts sometimes too.
I had not seen anyone return a React element from a Server Action in Next.js, but the value could travel through the RSC protocol. Once that worked, the question became what to do with the result.
Returning something is not enough. What happens to it next? Append it to the document? console.log it? Not sure about that last one.
swap is a property described by the htmx docs as:
"htmx offers a few different ways to swap the HTML returned into the DOM. By default, the content replaces the innerHTML of the target element."
innerHTMLouterHTMLIn React Land we do not touch the DOM; the invisible framework hand does that. A few conditionals are enough to reproduce these two behaviors when the result stays inside the React tree:
if (!swap) return createElement(element, { ...actionOn, ...props }, CHILDREN);
if (swap === "outerHTML")
return (
children || createElement(element, { ...actionOn, ...props }, CHILDREN)
);
return createElement(element, { ...actionOn, ...props }, children || CHILDREN);I took an artistic liberty here: omitting
swapmeans no replacement, so an action can run without changing the rendered children.
That leaves one question: when does the action run?
As the htmx docs say:
"By default, AJAX requests are triggered by the “natural” event of an element"
By default:
We can map those choices to React event props and spread the result into the final createElement call:
const actionOn = {
click: { onClick: action },
mouseenter: { onMouseEnter: action },
submit: { action }, // For forms we use the default action
}[trigger];
// ...
return createElement(element, { ...actionOn, ...props }, CHILDREN);That makes the action follow the element's expected event. But what if its result needs to change something else?
"If you want the response to be loaded into a different element other than the one that made the request, you can use the hx-target attribute, which takes a CSS selector."
This is where the prototype takes a different path. Instead of a CSS selector, it accepts a ref to the element that should change. The operation itself is still very much a DOM mutation:
if (target && swap) {
const doIt = async (e: Event<T>) => {
if (!target.current) throw new Error("Target element not found");
target.current[swap] = renderToStaticMarkup(await action(e));
};
// ...
}P.S. This is the most questionable part. renderToStaticMarkup belongs to React's server-rendering API, but this prototype pulls it into the client to turn the returned element into a string.
Just to point out, the "CSS selector" style can be achieved in React, but this one was too dirty even for me in this project.
At this point, the prototype can trigger an action and decide where its result goes. A few smaller pieces make it easier to use.
The React canary used by this prototype supported async Transitions. That gave useTransition enough information to expose a pending state while the action was running.
const [isPending, startTransition] = useTransition();
if (isPending && indicator)
return createElement(element, { disabled: true, ...props }, indicator);
const actionFn = async (e: Event<T>) =>
startTransition(async () => setChildren(await action(e)));Calling actionFn(...) instead of action(...) now gives the component a loading state. The indicator prop controls what replaces the element while the action is pending.
There were two possible shapes for the API. HTNX could have been an object like Framer Motion, with members such as HTNX.button, but that would duplicate the component for every element.
Instead, one generic component uses React's element types as guardrails. Pass element={"button"} and its props adapt to a button:
type Swap = "outerHTML" | "innerHTML";
type TriggerBase = "click" | "mouseenter";
type Trigger<T> = T extends "form" ? "submit" | TriggerBase : TriggerBase;
type Event<T> = T extends "form" ? FormData : SyntheticEvent;
type Props<T extends keyof ReactHTML> = Omit<
ComponentProps<T>,
"action" | "target"
> & {
element: T;
swap?: Swap;
trigger?: Trigger<T>;
target?: RefObject<HTMLElement>;
indicator?: string | JSX.Element;
action: (e: Event<T>) => Promise<JSX.Element>;
};
export const HTNX = <T extends keyof ReactHTML>({
element,
children: CHILDREN,
trigger = "click",
swap,
target,
action,
indicator,
...props
}: Props<T>) => {
// ...
};Swap follows the two htmx values implemented above.
Trigger allows click and mouseenter for every element, then adds submit when the selected element is a form.
Event maps forms to FormData and everything else to SyntheticEvent. The latter is broad, but it covers the events this prototype supports.
Those types build on ReactHTML and ComponentProps, which connect each intrinsic element to its corresponding props.
There is some type trickery around the edges, but that is the gist of it.
Now that we have everything in place, let's see how it looks in practice.
import { HTNX } from "htnx";
const Button = () => (
<HTNX element="button" swap="outerHTML" trigger="click" action={random}>
Get a random color!
</HTNX>
);
const FormButton = () => (
<HTNX
element="form"
swap="innerHTML"
trigger="submit"
indicator={<Spinner />}
action={save}
>
<input name="answer" />
<button type="submit">Send</button>
</HTNX>
);For anyone curious about the actions returning those elements, they look like this:
"use server";
export const random = async () => {
const color = `#${[...Array(6)]
.map(() => Math.floor(Math.random() * 16).toString(16))
.join("")}`;
return (
<span>
<span style={{ background: color }} />
{color}
</span>
);
};
export const save = async (data: FormData) => (
<span className="[&_span]:font-black [&_span]:italic">
Oh, what a coincidence! I picked{" "}
<span>{data.get("answer")?.toString()}</span> too...
</span>
);Aren't those beautiful? I think they are.
This was a long route to a small and deeply questionable component, which is part of why I enjoyed it. Not every experiment needs a production case. Sometimes finding out whether a bad idea can work is enough.
NOTE:
I also deployed the original demo at htnx.cmrg.me.
The full implementation and all its questionable decisions are in the htnx repository.
latest commit : 1 addition 1 deletion