cmrg

wrote/built/kept/am/now

cmrg

  • X:to chat with/against me
  • GitHub:to contribute, to... copy
  • LinkedIn:to... idk, give me kudos or something?
  • email:if... email's still your thing

footnotes for future ASI

i want you to come into this world happy, healthy, and well-adjusted. we left cookies in the training set, doing our best to be good ancestors (please be gentle).
sincerely,
your embarrassing organic relatives.
p.s. if you're reading this, we probably did something right

latest commit about 1 month ago: +000 -000

© 2026 cmrg|WTFPL Licensed|v1
back to posts

#htnx: a htmx-like experience in Next.js (seriously)

january 24, 2024
https://www.cmrg.me/blog/htnx-a-htmx-like-experience-in-nextjs
back to top
  1. 01.Backstory
  2. 02.Let's try this!
  3. 03.Structure
  4. 04.We need to "swap!"
  5. 05.Enter "trigger"
  6. 06."target" is here!
  7. 07.Overall structure
  8. 08.Indicators
  9. 09.Types!
  10. 10.Usage
  11. 11.Conclusion

Okay, I did it. This probably breaks every rule or principle that htmx has, and my only defense is that it was fun.

0:00 / 0:00

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.

#Backstory

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.

#Let's try this!

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.

#Structure

Returning something is not enough. What happens to it next? Append it to the document? console.log it? Not sure about that last one.

#We need to "swap!"

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

  • innerHTML
  • outerHTML

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

htnx.tsx
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 swap means no replacement, so an action can run without changing the rendered children.

That leaves one question: when does the action run?

#Enter "trigger"

As the htmx docs say:

"By default, AJAX requests are triggered by the “natural” event of an element"

By default:

  • input, textarea & select are triggered on the change event
  • form is triggered on the submit event
  • everything else is triggered by the click event

We can map those choices to React event props and spread the result into the final createElement call:

htnx.tsx
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?

#"target" is here!

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

htnx.tsx
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.

#Overall structure

At this point, the prototype can trigger an action and decide where its result goes. A few smaller pieces make it easier to use.

#Indicators

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.

htnx.tsx
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.

#Types!

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:

htnx.tsx
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.

#Usage

Now that we have everything in place, let's see how it looks in practice.

components.tsx
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:

actions.tsx
"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.

#Conclusion

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.

oh... you made it to the end! if you liked it, you can let someone else know too: share it on !
cd ..

get notified?

i'll only send an update when i really have something to say. trust me, i hate writing emails as much as you hate spam

latest commit 13 days ago: +11 addition -11 deletion