Skip to content

Instantly share code, notes, and snippets.

@dynnamitt
Last active March 22, 2026 16:30
Show Gist options
  • Select an option

  • Save dynnamitt/b641609d3ebf0b26457e107de77e4a23 to your computer and use it in GitHub Desktop.

Select an option

Save dynnamitt/b641609d3ebf0b26457e107de77e4a23 to your computer and use it in GitHub Desktop.
HTMX vs React: Critical Assessment for Hathor

HTMX vs React: Critical Assessment for Hathor

Context

Hathor is a CRUD admin panel for a national vehicle registry (Sobek). It displays ~2-3 entity types (VehicleTypes, DeckPlans) in data tables with sidebar editing, search, and an XML import wizard. The user dislikes React and wants to know if HTMX could simplify things.

Honest Assessment

What React is doing here (and whether it's earned)

The codebase is ~6,400 LOC across 101 files. For what amounts to 2 data tables, a search bar, and an import wizard, that's a lot of machinery. Here's what's genuinely complex vs accidental complexity:

Accidental complexity (React tax):

  • 5 nested context providers just to pass config/auth/search state down
  • 12 custom hooks, most of which are thin wrappers around useState + useEffect
  • ViewConfig generic pattern with 11+ type parameters for... 2 entity views
  • useDataViewTableLogic does client-side sort/filter/paginate on data that could come pre-sorted from the server
  • useUrlFilters bidirectional URL sync — 69 lines of useEffect choreography for what hx-push-url does in one attribute
  • SearchContext at 197 lines — a debounced text input with filter state

Genuinely complex client-side interactions:

  • Resizable sidebar panels (mouse drag tracking) — needs JS, but ~40 lines of vanilla JS
  • Multi-step import wizard with XML parsing (MultiImport.tsx, 276 lines) — real state machine
  • Drag-and-drop file upload — ~30 lines of vanilla JS
  • Runtime theme switching — nice-to-have, not essential

Could HTMX simplify this?

Yes, significantly — IF you control the backend. That's the real question.

Feature React (current) HTMX equivalent
Data table GraphQL fetch → client sort/filter/paginate → render hx-get="/vehicles?sort=name&page=2" → server returns <tbody>
Search 197-line SearchContext + debounce + suggestions hx-get="/search?q=..." hx-trigger="input changed delay:300ms" hx-target="#results"
Pagination Client-side slice of full dataset hx-get="/vehicles?page=3" hx-swap="innerHTML"
Sorting Client-side re-sort hx-get="/vehicles?sort=name&dir=asc" on column header click
Filters useUrlFilters + SearchContext + useEffect dance hx-get="/vehicles?filter=bus" hx-push-url="true"
Sidebar edit EditingContext + Sidebar + SidebarContent hx-get="/vehicles/123/edit" hx-target="#sidebar"
Auth OIDC client lib + ProtectedRoute + SessionContext Server-side session cookie + 401 redirect
i18n react-i18next client-side Server-side i18next (mature SSR support)
Import wizard 9 files, 7+ useState, step state machine Multi-step form with hx-post per step (server holds wizard state)

Estimated LOC reduction: 6,400 → ~1,500-2,000 (templates + ~200 lines of vanilla JS for resize/drag-drop).

The blockers (being honest)

  1. Sobek is a GraphQL API, not an HTML-rendering server. HTMX needs a server that returns HTML fragments. You'd need either:

    • A thin BFF (Backend-for-Frontend) layer that queries Sobek's GraphQL and renders HTML — e.g., Express/Fastify with a template engine, or Go with html/template
    • Or modify Sobek itself to serve HTML (unlikely if it's a shared API)
  2. Runtime theme switching — MUI's ThemeProvider makes this easy. With HTMX you'd use CSS custom properties (doable, arguably cleaner, but requires a rewrite of the theme system).

  3. The import wizard is the one genuinely complex piece. Multi-step form with client-side XML parsing (fast-xml-parser), column mapping, deduplication. This could work as a server-side wizard, but the XML parsing/assembly logic would need to move server-side too.

  4. MUI component library — you lose the pre-built table, drawer, dialog, stepper components. For an admin tool, this matters less (plain HTML tables work fine). But you'd need to build or find replacements for: modal dialogs, responsive drawer, stepper.

What HTMX would NOT simplify

  • Resizable panels (~40 lines of vanilla JS either way)
  • Drag-and-drop file upload (~30 lines either way)
  • Runtime theme switching (CSS custom properties vs MUI ThemeProvider — similar complexity, different approach)

Verdict

HTMX is a genuinely better fit for this use case — it's a CRUD admin panel with simple data flows. The current React setup has ~4x more code than necessary because React forces you to manage server state in the client.

But the migration isn't "swap React for HTMX." It's:

  1. Build a BFF server that queries Sobek and renders HTML templates
  2. Move sort/filter/paginate logic to the BFF (or add it to Sobek's GraphQL)
  3. Move auth from client-side OIDC to server-side session management
  4. Move XML import logic to the BFF
  5. Replace MUI components with plain HTML + CSS
  6. Keep ~200 lines of vanilla JS for resize/drag-drop

This is a rewrite, not a refactor. The question is whether the simplicity payoff justifies it. For a 2-table admin tool maintained by a small team? Probably yes — if you're comfortable with a BFF layer.

Implementation Plan: Express + Nunjucks + HTMX

Architecture

Browser (HTMX + ~200 LOC vanilla JS)
  ↓ simple GET/POST → receives HTML fragments
Express + Nunjucks templates
  ↓ graphql-request (reuse existing queries)
Sobek GraphQL API (unchanged)

What is the BFF (Backend-for-Frontend)? A thin Express server that:

  1. Receives plain HTTP requests from HTMX (GET /vehicles?page=2&sort=name)
  2. Queries Sobek's GraphQL with graphql-request (same lib the React app uses)
  3. Renders a Nunjucks template with the data
  4. Returns an HTML fragment that HTMX swaps into the page

The browser never sees GraphQL. It just asks for HTML and gets HTML.

Phase 1: BFF skeleton + vehicle type table

  • Express app with Nunjucks template engine
  • Server-side OIDC via openid-client (token in secure HttpOnly cookie)
  • Reuse existing GraphQL queries from src/graphql/vehicles/queries/
  • GET / → full page with layout, nav, empty table container
  • GET /vehicle-types<tbody> fragment with sorted/paginated rows
  • HTMX attributes: hx-get, hx-trigger="load", hx-swap="innerHTML", hx-push-url
  • Server-side sort/filter/paginate (trivial in JS: Array.sort().slice())
  • Debounced search: hx-trigger="input changed delay:300ms"

Phase 2: Sidebar editing

  • GET /vehicle-types/:id/edit → edit form HTML fragment
  • POST /vehicle-types/:id → save, return updated <tr> via hx-swap="outerHTML"
  • Vanilla JS resize handle (~40 lines, extracted from current useResizableSidebar.ts)

Phase 3: Deck plans table

  • Same pattern as vehicle types — new route, new template, reuse table layout partial

Phase 4: Import wizard

  • GET /import → step 1 form (file upload)
  • POST /import/parse → server parses XML (move fast-xml-parser logic server-side), returns step 2 (column mapping)
  • POST /import/review → returns step 3 (review/confirm)
  • POST /import/submit → POSTs NeTEx to Sobek, returns result
  • Wizard state held in server session (express-session)

Phase 5: Polish

  • CSS custom properties for theming (replace MUI ThemeProvider)
  • Server-side i18n with i18next (same lib, has Node.js support)
  • Responsive layout with CSS media queries + container queries
  • Port Playwright e2e tests (they test DOM selectors, not React internals)

Verification

  • Playwright e2e tests should port with minimal changes (same data-testid attrs)
  • Compare feature parity with current React app
  • Verify OIDC flow works with server-side session
  • Test with Sobek running locally at localhost:37999

What is a BFF and why HTMX needs one

BFF = Backend for Frontend. A thin server whose only job is to serve HTML for a specific UI. It sits between the browser and the "real" API (Sobek's GraphQL), translating structured data into rendered HTML fragments.

Why HTMX needs it: HTMX replaces client-side JS with server-rendered HTML partials. When the user clicks "page 2", HTMX sends GET /vehicles?page=2 and the server returns a <tbody> fragment. No JSON. No client-side rendering. The server does the rendering. Since Sobek only speaks GraphQL/JSON, you need something in front of it that can query the API and return HTML.

For Hathor, the BFF would be ~300-500 lines of Express routes + Nunjucks templates. It absorbs all the complexity currently spread across 12 React hooks and 5 context providers.

Further reading

  • When Should You Use Hypermedia? — HTMX.org essay on when hypermedia fits (CRUD, text-heavy, nested updates) vs when it doesn't (offline, complex client interdependencies)
  • A SPA Alternative — HTMX.org's case for HTML-centric dev as a simpler alternative to SPA frameworks like React
  • HTMX is the Future — Chris James argues HTMX offers a simpler path by returning to hypermedia-driven development with minimal JS
  • Server-driven web apps with HTMX — LogRocket tutorial: CRUD app with HTMX + Express + Supabase, covers partial HTML responses and AJAX attributes
  • Full Stack App with Express and HTMX — Step-by-step tutorial building an interactive app with Express backend and HTMX frontend
  • Backends For Frontends — Sam Newman's canonical article on the BFF pattern: why you create separate server-side backends tailored to each client interface

Critical files to study before deciding

  • src/pages/GenericDataViewPage.tsx — the core pattern you'd be replacing
  • src/hooks/useDataViewTableLogic.ts — client-side logic that moves to server
  • src/components/search/SearchContext.tsx — 197 lines that become 1 HTMX attribute
  • src/components/external-inputs/autosys/MultiImport.tsx — the hardest piece to migrate
  • src/data/vehicle-types/useVehicleTypes.ts — data fetching that the BFF absorbs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment