Home / Articles

One Todo Request, Two Architectures: What htmx Strips From a CRUD Stack

Trace a single add-item request through a React SPA and an htmx page to see which layers disappear, what the server takes over, and where htmx stops fitting.

Publisher: REACTAPP.TOP6 min read1479 words

This article is published in English.

Sources: Stop Over-Engineering CRUD Apps: Why HTMX Might Be All You Need

Most internal tools and admin panels do the same four things: create, read, update and delete records. Yet the default way to build them has become a full single-page application, with a component framework, a state manager, a data-fetching library, JSON endpoints, and client code whose main job is converting that JSON back into HTML. Somewhere along the way, a form that inserts one database row ends up depending on half a dozen packages and two build tools.

This article follows one concrete feature, adding an item to a todo list, through both a React implementation and an htmx implementation. Seeing the request lifecycle side by side makes it easy to judge which layers your own CRUD app genuinely needs, and when a heavier client framework is still the right call. For a broader comparison of hypermedia against the SPA default, see htmx vs the SPA default.

The requirements most CRUD apps actually have

Strip a typical business app down to what its users do. They sign in, create records, edit them and remove them. There is usually no offline mode, no real-time collaboration to reconcile, and no interaction so fast that it must be handled without a round trip to the server. Nothing on that list justifies diffing a virtual DOM, routing on the client, or waiting most of a minute for a toolchain to rebuild one button.

Frameworks are often picked by habit, before anyone writes down what the app must do. Starting from requirements usually leads to a much smaller toolset.

How htmx changes the contract with the server

htmx lets ordinary HTML elements issue HTTP requests through attributes. The server replies with an HTML fragment rather than JSON, and htmx inserts that fragment into the page at a location you specify.

The consequence is that there is no client-side copy of the data to manage. The server already knows what the list should look like, so it renders the finished markup and sends it. The browser only has to put it in the right place.

The same feature in both styles

The React version

Here is a conventional React component that adds a todo. It holds the input text and the list in state, posts JSON to an API, parses the response, appends the new item and clears the field.

function AddTodo() {
  const [text, setText] = useState("")
  const [todos, setTodos] = useState([])

const addTodo = async () => {
    const res = await fetch("/api/todos", {
      method: "POST",
      body: JSON.stringify({ text }),
    })
    const todo = await res.json()
    setTodos([...todos, todo])
    setText("")
  }
  return (
    <div>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={addTodo}>Add</button>
      <ul>
        {todos.map(t => <li key={t.id}>{t.text}</li>)}
      </ul>
    </div>
  )
}

Even this small example carries details you must get right. The request sends a JSON body without a Content-Type header, which many servers will not parse as JSON. The update setTodos([...todos, todo]) reads todos from the render in which the click happened, so two quick submissions can lose an item; the functional form setTodos(prev => [...prev, todo]) avoids that. And the list only contains items added in this session, since loading existing todos would need yet another effect and endpoint.

The htmx version

The htmx equivalent is a form and an empty list.

<form hx-post="/todos" hx-target="#list" hx-swap="beforeend">
  <input name="text" />
  <button>Add</button>
</form>

<ul id="list"></ul>

Three attributes carry the behaviour. hx-post="/todos" sends the form fields to that URL when the form is submitted. hx-target="#list" says where the response goes, and hx-swap="beforeend" appends it inside that element, after the existing children, instead of replacing its contents. The button needs no handler, because a button inside a form submits it by default.

On the server, the route reads the form field, stores the todo and renders one list row from a template.

@app.route("/todos", methods=["POST"])
def add_todo():
    text = request.form["text"]
    todo = save_todo(text)
    return render_template("todo_row.html", todo=todo)

That is the entire frontend for this feature: no hooks, no client state and no re-render behaviour to debug late at night. The todo_row.html template simply renders a single <li> for the new item. Because the same template can be used when the page is first rendered, the initial list and newly added rows share one piece of markup rather than one JSX component and one server view that must be kept in sync.

A couple of practical notes apply to real deployments. A state-changing request still needs CSRF protection, typically by including the token as a hidden field or a request header. And if you also give the form ordinary action and method attributes, it keeps working as a plain form submission when JavaScript fails to load.

Tracing the two request lifecycles

The difference is clearest when you draw each flow step by step. First the SPA route.

Browser                    Server
--------                   --------
Click button
   |
   v
JS event handler
   |
   v
fetch() call  ------->  API endpoint
                              |
                              v
                         Query database
                              |
                              v
                         Return JSON
   ^                          |
   |__________________________|
   |
   v
Parse JSON
   |
   v
Update state
   |
   v
Re-render component
   |
   v
Diff virtual DOM
   |
   v
Patch real DOM

After the database returns JSON, the client still has to parse it, update state, re-render, diff and patch the DOM.

Now the htmx route for the same click.

Browser                    Server
--------                   --------
Click button
   |
   v
htmx request  ------->  Route handler
                              |
                              v
                         Query database
                              |
                              v
                         Render HTML piece
   ^                          |
   |__________________________|
   |
   v
Swap HTML into page

Several stages are simply gone: client-side JSON parsing, state updates, diffing, and any effort to keep a client copy of the data consistent. The database behind the server owned this data all along. The htmx approach stops maintaining a second, client-side version of that truth.

What changes in practice

In one reported migration of a client admin project, replacing a React frontend with server-rendered templates plus htmx cut the JavaScript bundle from roughly 340 KB to about 12 KB, while time to interactive on a mid-range phone fell from nearly three seconds to below one second. Pull requests shrank because there was less code to review, and new developers could find their way around in an afternoon, since the codebase was mostly HTML with a few extra attributes.

Treat those numbers as an illustration, not a benchmark. They come from a single project measured informally on one machine and network. The direction of the effect is plausible for any app where most of the client code exists only to shuttle data between JSON and the DOM, but measure your own application before and after rather than assuming the same gains.

When htmx is the wrong tool

This is not an argument for removing React from everything. Applications with genuinely rich client-side interaction, such as design tools, spreadsheets or real-time collaborative editors, need substantial client state and a framework built to manage it. Forcing those into request-and-swap interactions would be a constant fight.

A quick way to decide is to ask a few questions about the feature in front of you:

  • Does the interaction need to respond faster than a network round trip, such as dragging, drawing or typing with instant feedback?
  • Must it work offline, or reconcile edits from several users in real time?
  • Does the client need to hold and transform a large amount of data locally?

If the answers are mostly no, the feature is probably forms, tables and buttons that save to a database. That category covers far more software than it is fashionable to admit, and for it the extra client machinery tends to add maintenance cost rather than solve a problem.

Wrapping up

  • Choose tools from requirements; plain CRUD rarely needs client-side state.
  • With htmx, the server renders fragments and attributes decide where they land, removing JSON translation and a duplicated source of truth.
  • Keep CSRF protection, real action attributes and shared row templates.
  • Reserve a full client framework for genuinely client-heavy interactions.

Learning React remains a sensible career move, and many projects need it. That justifies knowing the tool; it does not justify shipping it with every app whose real needs are a form and a table.