DEV Community

shroy
shroy

Posted on

Why I Built HookTML: React Vibes, Stimulus Roots.

I like staying close to the browser.

It already knows how to do a lot - rendering, accessibility, navigation, forms. The more I can lean on that built-in behavior, the less I have to reinvent or debug what the browser already handles well.

That’s part of why I’ve always liked tools like Stimulus, Hotwire, and HTMX. They let the browser handle what it’s already good at. But as my projects got more interactive, I started wanting something a little more composable, something that lets me reuse logic cleanly without giving up the simplicity of DOM-first development.

That’s the gap HookTML tries to fill.

The Context-Switching Struggle

At work, I use React with GraphQL and styled-components. It’s hook-heavy and composable. In my side projects, I was using Rails + Hotwire + Stimulus. And switching between those two mindsets wasn’t seamless.

In React, I can break down behavior into small, testable functions like useDropdown(), useClickOutside(), or useFocusTrap(), and compose them however I need.

In Stimulus, reuse tends to mean inheritance, mixins, or stacking multiple controllers on the same element. That can already feel a bit clunky, but it gets harder when behaviors need to coordinate across controllers. You end up wiring up outlets, managing controller lifecycles, or passing data around manually just to connect things that conceptually belong together.

All of this adds a layer of indirection that makes context switching even harder, especially when you’re used to React’s model of explicitly composing logic in one place.

What Is HookTML?

HookTML lets you sprinkle in reactive JavaScript using patterns that feel familiar if you’ve worked with React, while keeping your HTML plain and letting the browser stay in control.

It’s a tiny (2.2kB gzipped), HTML-first library that helps you:
• Attach behavior using use-* attributes
• Write simple, composable functions for interactivity
• Keep your HTML declarative and your JavaScript expressive
• Combine behaviors without reaching for a full framework

It also works with Hotwire and HTMX, so it plays nicely with dynamic HTML rendered from the server.

If you’re coming from React, a few things might feel familiar, but without the usual overhead.

What HookTML Keeps (and Skips) from React Hooks

I like how React lets you break behavior into small functions like useDropdown. That kind of composition makes complex UIs easier to manage, and I wanted to keep that part.

What I didn’t want were the stale closures, missing dependencies, or memoization that didn’t quite memoize enough.

HookTML reduces that overhead. There’s no hook call order to worry about, no virtual render cycle, and no component reconciliation to mentally track. You write plain JavaScript functions that attach directly to HTML with attributes like use-tooltip or use-dropdown.

Effects re-run when tracked signals change, or when you list dependencies. But you’re in control. No need to wrap half your app in useCallback just to stabilize re-renders.

It’s not “just like React”, but that’s the point. It handles just enough, and lets the browser do the rest—something I’ve always appreciated about Stimulus.

What I Loved (and Outgrew) in Stimulus

Stimulus is a really thoughtful library. I’ve always appreciated the idea of keeping JavaScript minimal and focused, just enough behavior, applied only where it’s needed. The way it encourages small, single-purpose controllers makes it easy to sprinkle in functionality without overengineering anything.

I used it in my side projects and genuinely enjoyed the simplicity, especially early on.

But as those projects grew, I found myself wanting more flexibility in how I composed behavior. I didn’t want to create a whole new controller every time I needed a slightly different interaction, or wire up two controllers that always appeared together. I wanted to extract small, reusable behaviors and snap them together without ceremony.

That’s the space I built HookTML to explore.

HookTML, by Example

Let’s start with a simple, focused behavior: a tooltip that shows when you hover over an element.

<span use-tooltip="This is a tooltip" tooltip-delay="300">?</span>
Enter fullscreen mode Exit fullscreen mode
// useTooltip.js
export const useTooltip = (trigger, props) => {
  const { value: text, delay } = props

  if (!text) return

  let timeout

  const show = () => {
    timeout = setTimeout(() => {
      // Create and show a tooltip near `trigger` with `text`
    }, Number(delay ?? 300))
  }

  const hide = () => {
    clearTimeout(timeout)
    // Remove or hide tooltip
  }

  useEvents(trigger, {
    mouseenter: show,
    mouseleave: hide
  })
}
Enter fullscreen mode Exit fullscreen mode

This is just a function.

It runs once when the element is mounted, and if you use signals or other HookTML helpers, it can respond to state changes and clean itself up automatically.

You can call it directly in another component, extract shared logic, test it in isolation, or combine it with other behavior hooks. You’re not locked into a controller lifecycle, and you don’t need to structure your code around inheritance or registration patterns to make it reusable.

The behavior is portable. The logic is transparent. And the HTML stays lean.

You also might notice I’m using use-tooltip instead of data-tooltip. Totally valid HTML. I just find it cleaner and less noisy, especially when you’re already juggling a bunch of data-* attributes for other tools. Still prefer data-*? Totally fine. HookTML lets you flip that switch in the config if it feels more familiar.

What about something more dynamic—like coordinating state across multiple elements?

Tabs with Shared State

Here’s how you could build a simple tab component using HookTML’s signal(), useEvents(), and useClasses() helpers.

<div class="Tabs">
  <button tabs-button class="active">Tab 1</button>
  <button tabs-button>Tab 2</button>

  <div tabs-panel class="active">Panel 1</div>
  <div tabs-panel>Panel 2</div>
</div>
Enter fullscreen mode Exit fullscreen mode
const Tabs = (element, props) => {
  const { buttons, panels } = props.children
  const activeTab = signal(0)

  useEvents(buttons, {
    click: (event, index) => {
      activeTab.value = index
    }
  })

  useClasses(buttons, {
    active: (el, i) => activeTab.value === i
  }, [activeTab])

  useClasses(panels, {
    active: (el, i) => activeTab.value === i
  }, [activeTab])
}
Enter fullscreen mode Exit fullscreen mode

This is the kind of declarative, reactive behavior I missed—without needing to spin up a React runtime. In Stimulus, building the same thing might look more like this:

<div 
  data-controller="tabs"
  data-tabs-active-class="active"
>
  <button data-tabs-target="button">Tab 1</button>
  <button data-tabs-target="button">Tab 2</button>

  <div data-tabs-target="panel">Panel 1</div>
  <div data-tabs-target="panel">Panel 2</div>
</div>
Enter fullscreen mode Exit fullscreen mode
// tabs_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["button", "panel"]
  static classes = ["active"]

  connect() {
    this.showActiveTab(0)
  }

  buttonTargetConnected(el) {
    el.addEventListener("click", () => {
      const index = this.buttonTargets.indexOf(el)
      this.showActiveTab(index)
    })
  }

  showActiveTab(index) {
    this.buttonTargets.forEach((btn, i) => {
      btn.classList.toggle(this.activeClass, i === index)
    })

    this.panelTargets.forEach((panel, i) => {
      panel.classList.toggle(this.activeClass, i === index)
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

Of course, both examples are intentionally simple, but that’s kind of the point. Even with something small, you can already see where each approach wants to go. With HookTML, you can build on top of these behaviors without ballooning complexity. With Stimulus, growth tends to involve a bit more structure (e.g., targets, classes, lifecycle methods) that can be powerful, but adds layers.

Personally, I found I preferred reading logic top-down in a function. I liked being able to see the shape of a behavior all in one place and compose it freely without needing to remember how different framework pieces tie together.

Not Trying to Replace Stimulus or Alpine

This isn’t a framework takedown. HookTML isn’t meant to “beat” Stimulus or replace Alpine. Honestly, it wouldn’t exist without them. I’ve always appreciated the spirit of those tools — minimal JavaScript, HTML in the driver’s seat, and just enough interactivity to get the job done.

I just wanted to explore a different shape.

What if behaviors were plain functions? What if you could compose them, test them, share them, and still write them using patterns that feel familiar if you’re coming from React? HookTML is my way of blending hook-style ergonomics with HTML-first thinking.

If you’re already using Stimulus or Alpine, there’s no reason to stop. But if you’ve ever found yourself craving a bit more structure or composability, HookTML might be worth a look.

A Note on Component Styles

One experimental feature I added is component-scoped CSS—kind of like a lightweight take on styled-components. I’ve always loved how you can co-locate styles with behavior, so I wanted something similar to help with prototyping.

In HookTML, when you register a component, you can give it a titleized class name like class="Tabs". This not only helps you identify components easily in DevTools, but also makes it simpler to target them in CSS and query them in JavaScript. If styles are defined in the component file, HookTML will auto-apply them when it’s mounted, but even if you skip that part, the class name makes it easy to style components from your own stylesheet.

It’s still early, and I’m not sure how essential it is long term—but it’s been fun to experiment with. Curious what others think.

Built with a Little Help from AI

While everyone else was using AI to spin up the next clever SaaS tool, I used it to build a small JavaScript library.

As a full-time engineer and a parent, I don’t have a ton of extra hours to burn. But thanks to AI, I could accelerate the boring parts like naming helpers, exploring variations, and refining patterns, while staying focused on the architecture and purpose of the library. Without that boost, I might not have had the time to ship anything at all.

To be clear, this wasn’t vibe-coded into existence. Every decision was intentional, every pattern debated. I used AI the way you might use a rubber duck, except this duck can refactor your function names and suggest better test coverage.

Try it out

npm install hooktml
Enter fullscreen mode Exit fullscreen mode
  • Less than 4KB gzipped
  • No build step required
  • Works great with Hotwire, HTMX, or plain HTML

View on GitHub and ⭐ if you’re into it.

Let me know if you build something with it, or just want to yell gently about attribute naming. I’d love to hear your thoughts.

Top comments (0)