Migrating Knockout to Alpine (+HTMX)

Some tips, examples, and strategies for how to migrate pages from Knockout to Alpine.js (+HTMX).

Overview

HQ is slowly moving away from Knockout.js. Knockout is largely unmaintained, community support is dwindling, and we now have better options. Going forward, we are standardizing on Alpine.js for lightweight interactivity and HTMX to take better advantage of Django’s server-side rendering.

This is a living guide.

As we discover new migration patterns, refine prompts, and run into new edge cases, this page will need updates. Whenever you learn something useful while working on a Knockout → Alpine (+HTMX) migration, please add or adjust examples, notes, or links here so the guide stays current and helpful for everyone.

Read the HTMX + Alpine.js guide for patterns and examples of using HTMX and Alpine in the HQ codebase.

At a high level, our long-term goals for JavaScript are:

  • Reduce JS complexity and avoid duplicating server-side logic in the browser
  • Rely on Django templates for most HTML and layout
  • Use Alpine for light, local interactivity
  • Use HTMX for server-coordinated interactions (forms, tables, filtering, pagination)

These are north star goals. Applying all of them everywhere during the Knockout migration would make the migration take far too long. For existing pages, it is acceptable to migrate in steps. For new JavaScript and new features, we should follow these goals more strictly.

For the Knockout → Alpine (+HTMX) migration, prefer a pragmatic approach:

  • First priority: remove Knockout and replace it with Alpine in a mostly one-to-one fashion, keeping existing behavior intact.
  • When it’s feasible (and worth the effort): move complex or business-critical logic from JavaScript into Django, using templates and HTMX. Treat the goals above as guidance, not a hard requirement for every migration.
  • Use this guide as a starting point when approaching a migration, and update it as new patterns, shortcuts, or pitfalls are discovered.

Step 1: Update Your Mental Model

Before changing any code, it helps to update how you think about state and rendering on HQ pages. In the Knockout era, most state lived in the browser. In the Alpine + HTMX world we are moving toward, Django is the primary source of truth, and the browser is mostly a thin layer of interactivity on top.

You can think of HTMX as “data-binding to the server”. Alpine handles small, local UI state. Django and HTMX work together to keep real data and HTML in sync.

Meet the players

Knockout (before)

Client-side MVVM. State lives in ko.observables and ko.computeds. HTML is generated or updated in the browser.

  • Browser owns most state
  • data-bind everywhere
  • Templates often live in JS or inline script tags
Alpine (now)

Lightweight component state in x-data. Uses plain JS objects and getters instead of observables.

  • Small, page-local state
  • x-model, x-show, @click
  • Primarily for transient UI behavior rather than core business logic
HTMX (now)

Server-driven updates. Django renders HTML; HTMX swaps it into the page in response to user actions.

Concept mapping: where things live now

This table maps familiar Knockout ideas to the new Alpine + HTMX patterns. The main shift is that business rules ideally move back into Django, while Alpine and HTMX focus on wiring up interactions. During migration, it is fine for some logic to stay in JavaScript as an intermediate step.

Concept Knockout Alpine HTMX / Django
Source of truth JS view model (objects with observables) Small JS objects inside x-data Django view + DB.
Templates render HTML partials.
Data model ko.observable, ko.observableArray, ko.computed Plain properties and getters on the x-data object; $watch for side effects Context variables passed from Django to templates
Bindings / templating data-bind="text: name", KO templates (<!-- ko if -->) x-text, x-model, x-show, x-for, @click, and custom directives like x-select2 and x-datepicker (and more). Django templates for base rendering.
HTMX endpoints (via HqHtmxActionMixin ) return partials for updates.
Where business logic lives Often inside the KO view model (JS) Ideally: only light UI logic.
In practice (for migration): it is fine for existing business logic to live in an Alpine.data model, especially for more complex or shared models in JS files.
Django views, forms, and template conditionals
How state changes Update observables
→ Knockout re-renders bindings
Update x-data properties
→ Alpine re-runs effects
User action triggers an HTMX request (e.g. hx-post)
→ Django returns new HTML
→ HTMX swaps it into the DOM
Typical use cases Rich client-side apps, custom widgets, lots of in-browser logic Small, local UI behavior and glue code:
toggles, dialogs, complex form validation, “isLoading” flags, simple multi-step forms, shared components/interactions.
Alpine is fully capable of richer client-side interactions, but our default pattern is to keep long-lived business state in Django.
Anything where the real state lives in the database/backend:
tables/reports, search/filter forms, create/edit flows, complex multi-step forms (taking advantage of Django form validation).

Step 2: Mapping Knockout Bindings to Alpine

This section shows a few different ways to translate a Knockout model into an Alpine model, starting from the simplest case and building up.

Example 1: Simple Model, Keep it Local

This example shows a very small model where the logic is simple enough that it doesn't need its own JavaScript file. Everything can live directly in the HTML via x-data and x-model.

Knockout Source

Name:

Count:

Hello there!
HTML
<div id="simple-ko-start-example" class="ko-template">
  <p>
    Name:
    <input
      type="text"
      class="form-control"
      data-bind="textInput: name"
    />
  </p>
  <p data-bind="text: greeting"></p>
  <p>
    <button
      type="button"
      class="btn btn-outline-primary"
      data-bind="click: incrementCount"
    >Increment</button>
  </p>
  <p>
    Count: <span class="badge bg-secondary" data-bind="text: count"></span>
  </p>
  <p>
    <button
      type="button"
      class="btn btn-outline-primary"
      data-bind="click: toggleVisibility"
    >Toggle Message</button>
  </p>
  <div data-bind="visible: isVisible">
    Hello there!
  </div>
</div>
JS
import $ from 'jquery';
import ko from 'knockout';
import initialPageData from 'hqwebapp/js/initial_page_data';

$(function () {
    var simpleModel = function () {
        let self = {};
        self.name = ko.observable(initialPageData.get("ko_simple_name"));
        self.count = ko.observable(0);
        self.isVisible = ko.observable(false);
        self.greeting = ko.computed(function () {
            return 'Hello, ' + self.name() + '!';
        });
        self.incrementCount = function () {
            self.count(self.count() + 1);
        };
        self.toggleVisibility = function () {
            self.isVisible(!self.isVisible());
        };
        return self;
    };
    $("#simple-ko-start-example").koApplyBindings(simpleModel());
});

Migration to Alpine

Name:

Count:

Hello there!
HTML
<div
  x-data="{
    name: 'Fred',
    count: 0,
    isVisible: false,
    get greeting() {
      return `Hello, ${this.name}!`;
    },
  }"
>
  <p>
    Name:
    <input
      type="text"
      class="form-control"
      x-model="name"
    />
  </p>
  <p x-text="greeting"></p>
  <p>
    <button
      type="button"
      class="btn btn-outline-primary"
      @click="count++"
    >Increment</button>
  </p>
  <p>
    Count: <span class="badge bg-secondary" x-text="count"></span>
  </p>
  <p>
    <button
      type="button"
      class="btn btn-outline-primary"
      @click="isVisible = !isVisible"
    >Toggle Message</button>
  </p>
  <div x-show="isVisible" x-cloak="">
    Hello there!
  </div>
</div>

All of the state that used to live in a Knockout view model now lives in a tiny Alpine component attached directly to the markup.

Look, ma, no extra JavaScript file!

Example 2: Complex Model Approaches

This example covers a slightly richer model where it's more reasonable to keep the state and behavior in a JavaScript module instead of inline in the template.

The example is deliberately much simpler than real "complex" models in HQ. It's mainly a placeholder to illustrate the core Alpine patterns involved in splitting a model out into separate JavaScript files.

Knockout Source

HTML
<!--
 We can't use template tags in this file, but we would include
 the following tag on the same page for initial data:
 {% initial_page_data "ko_complex_initial_value" complex_initial_value|JSON %}
-->
<div id="complex-ko-start-example" class="ko-template">
  <!-- ko foreach: keyValuePairs -->
    <div class="d-flex">
      <div class="flex-fill">
        <input
          type="text"
          class="form-control"
          data-bind="textInput: key"
        />
      </div>
      <p class="py-1 px-3">&rarr;</p>
      <div class="flex-fill">
        <input
          type="text"
          class="form-control"
          data-bind="textInput: value"
        />
      </div>
      <p class="ps-3">
        <button
          type="button"
          class="btn btn-outline-danger"
          data-bind="click: $root.removeKeyValuePair"
        ><i class="fa fa-remove"></i></button>
      </p>
    </div>
  <!-- /ko -->
  <p>
    <button
      type="button"
      class="btn btn-primary"
      data-bind="click: addKeyValuePair"
    ><i class="fa fa-plus"></i> Add</button>
  </p>
</div>
JS
import $ from 'jquery';
import ko from 'knockout';
import initialPageData from 'hqwebapp/js/initial_page_data';

$(function () {
    var kvModel = function (key, value) {
        let self = {};
        self.key = ko.observable(key);
        self.value = ko.observable(value);
        return self;
    };
    var complexModel = function (initialData) {
        let self = {};
        self.keyValuePairs = ko.observableArray(initialData.map(function(item) {
            return kvModel(item.key, item.value);
        }));
        self.addKeyValuePair = function () {
            self.keyValuePairs.push(kvModel('', ''));
        };
        self.removeKeyValuePair = function (pair) {
            self.keyValuePairs.remove(pair);
        };
        return self;
    };
    $("#complex-ko-start-example").koApplyBindings(complexModel(initialPageData.get("complex_initial_value")));
});

Migration to Alpine

HTML
<!--
  We can't use template tags in this file, but we would define
  x-data in the <div> below as follows:
  <div
    x-data="complexExample({{ complex_initial_value|JSON }})"
  >
-->
<div x-data="complexExample([{&quot;key&quot;: &quot;color&quot;, &quot;value&quot;: &quot;Purple&quot;}])">
  <template x-for="(pair, index) in keyValuePairs">
    <div class="d-flex">
      <div class="flex-fill">
        <input
          type="text"
          class="form-control"
          x-model="pair.key"
        />
      </div>
      <p class="py-1 px-3">&rarr;</p>
      <div class="flex-fill">
        <input
          type="text"
          class="form-control"
          x-model="pair.value"
        />
      </div>
      <p class="ps-3">
        <button
          type="button"
          class="btn btn-outline-danger"
          @click="removeKeyValuePair(index)"
        ><i class="fa fa-remove"></i></button>
      </p>
    </div>
  </template>
  <p>
    <button
      type="button"
      class="btn btn-primary"
      @click="addKeyValuePair"
    ><i class="fa fa-plus"></i> Add</button>
  </p>
</div>
JS
import Alpine from 'alpinejs';

Alpine.data('complexExample', (initialValue) => ({
    keyValuePairs: initialValue.map(item => ({
        key: item.key,
        value: item.value,
    })),
    addKeyValuePair() {
        this.keyValuePairs.push({ key: '', value: '' });
    },
    removeKeyValuePair(index) {
        this.keyValuePairs.splice(index, 1);
    },
}));

// If this was its own file, we would also start Alpine here:
// Alpine.start();

Migration to Alpine... but make it reusable!

If we want to make the above model reusable, we first define a module like this:

JS
export default (initialValue) => ({
    keyValuePairs: initialValue.map(item => ({
        key: item.key,
        value: item.value,
    })),
    addKeyValuePair() {
        this.keyValuePairs.push({ key: '', value: '' });
    },
    removeKeyValuePair(index) {
        this.keyValuePairs.splice(index, 1);
    },
});

Then we can use it in one or more modules / js_entry points as follows:

JS
import Alpine from 'alpinejs';

import complexModel from "styleguide/js/examples/ko_migration/alpine_complex_reusable";
Alpine.data('complexExample', complexModel);

// Alternatively, you can load initial data from initialPageData here
import initialPageData from 'hqwebapp/js/initial_page_data';
Alpine.data('complexExample', () => complexModel(initialPageData.get("complex_initial_value")));

Alpine.start();

When using this model in a template, the HTML is the same as the Alpine example above.

Migration to HTMX?

The Alpine version keeps the key/value pairs entirely in the browser: the keyValuePairs array lives in JavaScript, and changes are only persisted when you explicitly save them somewhere. For some flows, that's exactly what you want.

But if you'd rather have Django own the state — and immediately persist changes — you can move this example to HTMX. Instead of maintaining a JS array, the server stores the list of pairs and responds to HTMX requests when you add, edit, or delete a row.

For this demo, we use a small CacheStore (similar to the To-Do List example ) to simulate server-side storage:

Python
from corehq.apps.prototype.models.cache_store import CacheStore


class KeyValuePairStore(CacheStore):
    """
    CacheStore is a helpful prototyping tool when you need to store
    data on the server side for styleguide / demo HTMX views.

    Caution: Please don't use this for real features. It isn't battle-tested yet.
    """
    slug = 'styleguide-key-value-pairs'
    initial_value = [
        {
            "id": 1,
            "key": "color",
            "value": "Purple",
        },
    ]

The view, HtmxKeyValuePairsDemoView, mixes in HqHtmxActionMixin and exposes four HTMX actions:

  • load_pairs — load the current list of key/value pairs
  • add_pair — append a new empty row
  • update_pair — update the key or value of a row
  • delete_pair — remove a row
Python
from django.http import HttpResponse
from django.urls import reverse
from django.utils.decorators import method_decorator

from corehq.apps.domain.decorators import login_required
from corehq.apps.hqwebapp.decorators import use_bootstrap5
from corehq.apps.hqwebapp.views import BasePageView
from corehq.apps.styleguide.examples.bootstrap5.htmx_complex_store import KeyValuePairStore
from corehq.util.htmx_action import HqHtmxActionMixin, hq_hx_action


@method_decorator(login_required, name='dispatch')
@method_decorator(use_bootstrap5, name='dispatch')
class HtmxKeyValuePairsDemoView(HqHtmxActionMixin, BasePageView):
    """
    A simple HTMX demo that manages key/value pairs on the server side
    using a CacheStore instead of a client-side array.
    """

    urlname = 'sg_htmx_key_value_demo'
    template_name = 'styleguide/htmx_key_values/main.html'
    list_template = 'styleguide/htmx_key_values/partial.html'
    error_value_template = 'styleguide/htmx_key_values/field_with_error.html'

    @property
    def page_url(self):
        return reverse(self.urlname)

    def _store(self):
        return KeyValuePairStore(self.request)

    def get_pairs(self):
        return self._store().get()

    def save_pairs(self, pairs):
        self._store().set(pairs)

    def _next_id(self, pairs):
        if not pairs:
            return 1
        return max(p['id'] for p in pairs) + 1

    def render_pairs(self, request):
        """
        Helper to render the partial containing the list. We always target the
        same container in the template and let HTMX replace it.
        """
        return self.render_htmx_partial_response(
            request,
            self.list_template,
            {
                'pairs': self.get_pairs(),
            },
        )

    @hq_hx_action('get')
    def load_pairs(self, request, *args, **kwargs):
        """
        Load the current list of key/value pairs.
        """
        return self.render_pairs(request)

    @hq_hx_action('post')
    def add_pair(self, request, *args, **kwargs):
        """
        Add a new, empty key/value row at the end of the list.
        """
        pairs = self.get_pairs()
        pairs.append(
            {
                'id': self._next_id(pairs),
                'key': '',
                'value': '',
            }
        )
        self.save_pairs(pairs)
        return self.render_pairs(request)

    @hq_hx_action('post')
    def update_pair(self, request, *args, **kwargs):
        """
        Update a single field ('key' or 'value') for a specific row.
        """
        pair_id = int(request.POST['id'])
        field = request.POST['field']  # "key" or "value"
        new_value = request.POST.get(field, '')

        pairs = self.get_pairs()
        pair = next((p for p in pairs if p['id'] == pair_id), None)

        if pair is None or field not in ('key', 'value'):
            # Example of a generic error (could also raise HtmxResponseException)
            response = HttpResponse('Invalid pair', status=400)
            return response

        pairs = self.get_pairs()
        for pair in pairs:
            if pair['id'] == pair_id and field in ('key', 'value'):
                pair[field] = new_value
                break

        has_error = field == 'value' and not new_value.strip()
        had_error = field == 'value' and 'error' in request.POST
        if has_error or had_error:
            # Render just the field-with-error fragment
            context = {'pair': pair}
            if has_error:
                context['error'] = 'Value cannot be blank.'
            response = self.render_htmx_partial_response(
                request,
                self.error_value_template,
                context,
            )
            # Override hx-swap="none" and target a specific wrapper element
            response['HX-Reswap'] = 'outerHTML'
            response['HX-Retarget'] = f'#pair-{pair_id}-value'
            return response

        self.save_pairs(pairs)
        return self.render_htmx_no_response(request)

    @hq_hx_action('post')
    def delete_pair(self, request, *args, **kwargs):
        """
        Remove a row entirely.
        """
        pair_id = int(request.POST['id'])
        pairs = [p for p in self.get_pairs() if p['id'] != pair_id]
        self.save_pairs(pairs)
        return self.render_pairs(request)

The main template just sets up the JS entry point (the standard hqwebapp/js/htmx_and_alpine entry is fine) and defines a container that loads the list via HTMX:

Django
{% extends "hqwebapp/bootstrap5/base_navigation.html" %}
{% load hq_shared_tags %}

{# No page-specific JS needed; use the shared HTMX + Alpine entry point #}
{% js_entry "hqwebapp/js/htmx_and_alpine" %}

{% block content %}
  <div class="container py-4">
    <h1 class="mb-3">
      Key / Value Pairs (HTMX version)
    </h1>
    <p class="text-muted mb-2">
      This example manages key/value pairs entirely on the server. HTMX sends
      small requests when you add, edit, or delete a row, and the view returns
      an updated HTML partial.
    </p>
    <p class="text-muted mb-4">
      TIP: leave a value field blank to trigger an error response.
    </p>

    <div
      id="kv-list-container"
      hx-get="{{ request.path_info }}"
      hq-hx-action="load_pairs"
      hx-trigger="load"
    >
      <div class="htmx-indicator">
        <i class="fa-solid fa-spinner fa-spin"></i> Loading...
      </div>
    </div>
  </div>
{% endblock content %}

{% block modals %}
  {# Standard HTMX error modal #}
  {% include "hqwebapp/htmx/error_modal.html" %}
  {{ block.super }}
{% endblock modals %}

The partial template renders the list of pairs and wires each input and button to one of the HTMX actions above:

Django
<div
  id="kv-list-container"
  hx-swap="outerHTML"
>
  <div class="d-flex flex-column gap-2 mb-3">
    {% for pair in pairs %}
      <div class="d-flex align-items-center">
        <div class="flex-fill me-2">
          <input
            type="text"
            class="form-control"
            name="key"
            value="{{ pair.key }}"
            hx-post="{{ request.path_info }}"
            hq-hx-action="update_pair"
            {# note: with hx-vals, make sure you don't have a trailing comma and use double quotes in the JSON #}
            hx-vals='{
              "id": "{{ pair.id }}",
              "field": "key"
            }'
            hx-trigger="change, blur"
            {# if we use outerHTML here, we loose state of where the keyboard is focused #}
            hx-swap="none"
            hx-disabled-elt="this"
          />
        </div>
        <span class="px-2">&rarr;</span>
        <div class="flex-fill me-2">
          {# this id below will come in handy for validation #}
          <div id="pair-{{ pair.id }}-value">
            <input
              type="text"
              class="form-control"
              name="value"
              value="{{ pair.value }}"
              hx-post="{{ request.path_info }}"
              hq-hx-action="update_pair"
              hx-vals='{
                "id": "{{ pair.id }}",
                "field": "value"
              }'
              hx-trigger="change, blur"
              {# if we use outerHTML here, we loose state of where the keyboard is focused #}
              hx-swap="none"
              hx-disabled-elt="this"
            />
          </div>
        </div>
        <button
          type="button"
          class="btn btn-outline-danger"
          hx-post="{{ request.path_info }}"
          hq-hx-action="delete_pair"
          hx-vals='{
            "id": "{{ pair.id }}"
          }'
          hx-target="#kv-list-container"
          hx-swap="outerHTML"
          hx-disabled-elt="this"
        >
          <i class="fa fa-remove"></i>
        </button>
      </div>
    {% empty %}
      <p class="text-muted fst-italic mb-0">
        No key/value pairs yet.
      </p>
    {% endfor %}
  </div>

  <p>
    <button
      type="button"
      class="btn btn-primary"
      hx-post="{{ request.path_info }}"
      hq-hx-action="add_pair"
      hx-target="#kv-list-container"
      hx-swap="outerHTML"
      hx-disabled-elt="this"
    >
      <i class="fa fa-plus"></i>
      Add
    </button>
  </p>
</div>

Pattern: inputs that are already showing the latest value don't need new HTML on every change. In the key/value demo, each input uses hx-swap="none" and the view's update_pair action returns render_htmx_no_response(). This saves to the server without replacing any DOM, so focus and keyboard navigation stay exactly where the user left them.

Be careful if you need to handle input validation or if update_pair can hit database errors. In those cases you have two main options:

  • Listen for htmx:afterRequest and show a global or inline error (for example, using the response status or a custom header), or
  • Return a small error partial and override hx-swap / hx-target for that response using the HX-Reswap and HX-Retarget response headers.
    Note: We do this in the example if you leave the field blank.
    Django
    {% load hq_shared_tags %}
    <div id="pair-{{ pair.id }}-value">
      <input
        type="text"
        name="value"
        class="form-control {% if error %}is-invalid{% endif %}"
        value="{{ pair.value }}"
        hx-post="{{ request.path_info }}"
        hq-hx-action="update_pair"
        hx-vals='{
          "id": "{{ pair.id }}",
          "field": "value",
          "error": {{ error|BOOL }}
        }'
        hx-trigger="change, blur"
        hx-swap="none"
      >
      {% if error %}
        <div class="invalid-feedback">
          {{ error }}
        </div>
      {% endif %}
    </div>
    

Concretely, each input:

  • posts to the same view URL,
  • sends the row id and which field changed (field="key" or "value") via hx-vals,
  • triggers on change/blur, and
  • sets hx-swap="none" so HTMX never re-renders the input markup.

The update_pair handler then:

  • looks up the matching pair in the CacheStore,
  • updates just that one field on the server, and
  • returns self.render_htmx_no_response() (no HTML body).

Compared to the Alpine version:

  • There is no client-side array at all.
  • Each change (typing into a field, adding, deleting) immediately updates the server-side list via HTMX.
  • The source of truth lives entirely in Django; the DOM is just a reflection of that state.
  • Because updates use hx-swap="none", keyboard users can tab between fields while requests are in flight without losing focus.

This is a good fit when the "real" model is on the server and you want instant saves without introducing extra JavaScript state or explicit "Save" buttons.

Step 3: When to Stop at Alpine vs Move to HTMX

Once you've translated a Knockout model into Alpine, the next question is: do we stop here, or should this really live on the server with HTMX?

There isn't a single "right" answer, but there are some good rules of thumb that can help keep both the migration and long-term maintenance sane.

Stay with Alpine when...

  • The state is purely UI-level: show/hide, tabs, accordions, steps in a wizard, “isLoading” flags, inline edit mode, etc.
  • The data is derived from what's already on the page: filters that only affect what's rendered, client-side sort order, small in-memory lists that don't need to sync to the server immediately.
  • The logic is simple and local: one page, one small component, no other views need to know about it.
  • A brief delay to save is okay, or a single explicit “Save” button is fine.
  • You're doing a first-pass Knockout → Alpine migration and just want to remove KO without reshaping the whole feature yet.

Reach for HTMX when...

  • The real source of truth is in the database or external systems, and you don't want to duplicate that model in JavaScript.
  • Multiple users or processes may change the data, so the browser needs to pull fresh HTML from the server to stay in sync.
  • The logic involves permissions, complex validation, or side effects (e.g. saving to multiple tables, enqueueing tasks, audit logs).
  • The Knockout code is already re-implementing a lot of Django logic (form validation, choice lists, business rules) that you'd rather move back to Django.
  • You want "instant save" semantics—each change should be persisted immediately, not just when someone remembers to press "Save".

Migration-friendly rule of thumb:

  • For existing Knockout pages, it's fine to stop at Alpine if the logic is working and not clearly painful. You've already won by removing KO.
  • Reach for HTMX + Django when the Knockout code is clearly duplicating backend rules, or when bugs keep appearing because the browser and server have drifted apart.

The key/value example above shows this spectrum in practice:

  • The Alpine version keeps a local keyValuePairs array in JavaScript and only saves when you decide to.
  • The HTMX version drops the array entirely; the server owns the list, and each change is persisted via HTMX calls (using hx-swap="none" so focus isn't lost).

Both are valid patterns. The main question is where you want the long-lived model to live: Alpine for small, local UI state; HTMX + Django for data that really belongs on the server.

Using AI to Help with Migrations (Safely)

Migrating Knockout to Alpine (+HTMX) is very pattern-heavy. That makes it a great fit for code assistants / AI tools—as long as we keep tight guardrails and review changes carefully.

Where AI is especially helpful

  • Mechanical translation of bindings: turning data-bind expressions into x-model, x-text, x-show, and small Alpine methods.
  • Creating Alpine skeletons: building out an x-data model from an existing Knockout view model (properties, computed values, event handlers).
  • HTMX wiring: stubbing out hx-get / hx-post attributes, hx-target containers, and hq-hx-action names for a view that uses HqHtmxActionMixin.
  • Docs and comments: summarizing what a Knockout model does and turning that into docstrings, inline comments, or styleguide text (if you want to document some more adventures here).

How to prompt in the HQ context

When asking an AI tool to help with a migration, give it enough context so it can follow HQ patterns:

  • Include the Knockout JS and the relevant template snippet together (bindings + markup).
  • Mention that you are using Alpine + HTMX + Django, and that the page is on Bootstrap 5.
  • Be explicit about the target pattern, for example:
    • “Translate these data-bind attributes to Alpine (x-model, x-text, x-show). Keep the HTML structure and CSS classes the same.”
    • “Create a HqHtmxActionMixin-based view and HTMX attributes for these actions. Use hq-hx-action names that match the method names.”
  • Ask for small, focused changes: “only migrate this one widget” or “just the bindings, no visual redesign.”

Guardrails: things to watch out for

  • Behavior must stay the same. For migration work, treat the AI output as a proposal. Review it like a PR from a new teammate: does it really match the old behavior, including edge cases?
  • Don't accept giant rewrites. If an AI tool tries to “modernize” the entire page (rename everything, split files, change semantics), back up and ask for a smaller, narrower transformation.
  • Keep a tight diff: prefer migrations where the HTML and structure stay mostly the same and only bindings / models change. That makes it much easier to review and debug.
  • Run the tests (and the UI). After applying AI-generated changes, run existing tests and click through the page with both old and new flows in mind (including keyboard-only navigation).
  • Watch for subtle regressions: lost accessibility attributes, missing required flags, changed defaults, or behavior that depended on Knockout quirks.

Practical workflow:

  • Pick one widget / panel / form at a time.
  • Paste the Knockout model + template into your code assistant and ask for an Alpine (or HTMX) version that preserves behavior.
  • Review, trim, and adapt the suggestion to fit HQ patterns (js_entry usage, HqHtmxActionMixin, Bootstrap 5).
  • Test locally, then commit as a small, reviewable change.

Copy-paste prompt snippets

You don't have to rewrite the same context every time you ask an AI tool for help. Below are some "starter" snippets you can copy/paste into your prompts and then follow with the specific code you're working on.

General HQ migration context (use this in most prompts)

Paste this once at the start of a conversation to set the scene:

Markdown
Context about this project and what I'm doing:

- This code is from a large Django + Bootstrap 5 web app.
- Historically it used Knockout.js for client-side view models.
- We are now migrating to:
  - Alpine.js for lightweight interactivity and small, local UI state, and
  - HTMX for server-coordinated interactions, where Django renders HTML partials.

High-level goals:

- Reduce JavaScript complexity and avoid duplicating server-side logic in the browser.
- Rely on Django templates for most HTML and layout.
- Use Alpine for light, local interactivity and UI-only state.
- Use HTMX for forms, tables, filtering, pagination, and other server-backed actions.

Codebase-specific detail:

- We have a helper mixin called `HqHtmxActionMixin` in our codebase.
- It's a Django class-based-view mixin that:
  - Reads a custom request header like `HQ-HX-Action`.
  - Routes the request to a method on the view whose name matches that action.
  - Those methods are decorated with `@hq_hx_action("get" | "post" | "any_method")`.
  - In templates, we call them via `hq-hx-action="method_name"` together with `hx-get`/`hx-post`.
- You don't need to change the internals of `HqHtmxActionMixin`; just use it in examples.

<note: if you can, include the htmx_action.py file for reference>

Important constraints:

- Preserve existing behavior and user-facing semantics.
- Don't redesign the UI, just translate bindings / models.
- Keep diffs small and reviewable.
- Keep Django template logic and context variables intact where possible.

I'll paste Knockout JS and related templates next. Please work within this context.

KO → inline Alpine (simple widget, no extra JS file)

Use this when you just want to move a small KO widget into inline Alpine:

Markdown
Task:

Translate this Knockout-based widget into Alpine.js, using inline Alpine in the template.

Assume the following about the project:

- It's a Django + Bootstrap 5 app.
- Alpine.js is already loaded globally on the page.
- We are slowly migrating away from Knockout.js, but we don't want to change server-side code.

Constraints:

- Keep the HTML structure, CSS classes, and behavior the same for the user.
- Replace Knockout `data-bind` attributes with Alpine attributes like `x-model`, `x-text`,
  `x-show`, `x-for`, and small methods declared in `x-data`.
- Do NOT create a separate JavaScript file for this example; keep Alpine inline, e.g.:

  <div x-data="{ ... }">...</div>

- Do NOT change Django template tags or server-side logic.

Output:

1. The updated HTML snippet using Alpine.
2. A brief explanation (1-3 bullet points) of what you changed and why.

Code to migrate (Knockout template + JS):

<paste Knockout code here>

KO → Alpine module (Alpine.data, reusable model)

Use this for larger models that deserve their own JS module:

Markdown
Task:

Convert this Knockout view model into a reusable Alpine.js component defined in a separate JS module.

Project context (so you don't have to guess):

- Django + Bootstrap 5 app.
- Alpine.js is our main front-end library for local UI state.
- Initial values can be passed from the server via a global `initialPageData` helper or
  similar mechanism.

Constraints:

- Preserve the behavior of the existing Knockout model (same fields, defaults, and events).
- Define the Alpine model as a function that returns a data object, e.g.:

  export default (initialValue) => ({
      // properties and methods here
  });

- Show how to register it in a JS entry file with:

  import Alpine from "alpinejs";
  import myModel from "path/to/module";

  Alpine.data("myModelName", () => myModel(initialValue));
  Alpine.start();

- Do NOT change backend logic or Django context variables.

Output:

1. A JS module exporting the Alpine model (`export default (initial) => ({ ... })`).
2. A JS entry file snippet that imports it, registers `Alpine.data`, and calls `Alpine.start()`.
3. If needed, an example HTML snippet showing `x-data="myModelName(...)"`.

Existing Knockout code to migrate:

<paste KO model + relevant HTML here, or reference files>

KO → HTMX (+ Alpine for small UI state)

Use this when you want to move the “real” state to the server and keep Alpine just for UI sugar:

Markdown
Task:

Help me migrate this Knockout-driven UI to a server-driven pattern using HTMX,
with Alpine.js used only for small UI state (like inline-edit toggles).

Project context:

- Django + Bootstrap 5 app.
- HTMX is available and we prefer HTML responses over JSON.
- In this codebase we use a helper mixin, `HqHtmxActionMixin`, which:
  - Is mixed into Django class-based views.
  - Routes HTMX requests based on a custom header (e.g. `HQ-HX-Action`) to methods
    decorated with `@hq_hx_action("get" | "post")`.
  - In templates, we add `hq-hx-action="method_name"` to elements, alongside
    `hx-get`/`hx-post` pointing at the view URL.

Goals:

- Move long-lived or business-critical state and validation into Django, using HTMX.
- Use Alpine only for local UI state (e.g. `isEditing`, `isSubmitting`, toggles, etc.).
- Replace Knockout bindings with:
  - HTMX calls to server-side actions for data changes.
  - Alpine for small interactive pieces that don't need server involvement.

Constraints:

- Keep user-visible behavior the same (no UX redesign).
- Suggest a small set of HTMX actions (e.g. `load_items`, `add_item`, `update_item`, `delete_item`).
- Use common HTMX attributes: `hx-get`, `hx-post`, `hx-target`, `hx-swap`, `hx-trigger`,
  and `hx-disabled-elt`.
- When inputs don't need new HTML on every change, consider using `hx-swap="none"` and a
  “no content” response pattern.

Output:

1. A sketch of a Django view class using `HqHtmxActionMixin` with a few `@hq_hx_action` methods.
2. A main template snippet showing a container that loads a partial with HTMX.
3. A partial template snippet wiring forms/inputs/buttons to those HTMX actions.
4. Any small Alpine snippets needed for purely UI concerns.

Existing Knockout template + JS to migrate:

<paste KO code here>

Super-short “one-liner” context

When you're in a hurry, prepend this to a quick question:

Markdown
You have never seen this project before. It is a Django + Bootstrap 5 app
that is migrating from Knockout.js to Alpine.js and HTMX.

- Alpine is for light, local UI state.
- HTMX is for server-driven interactions with HTML partials returned by Django.
- We have a helper mixin called `HqHtmxActionMixin` that routes HTMX requests to
  methods decorated with `@hq_hx_action`, called from templates via `hq-hx-action="..."`.

Please keep behavior identical, avoid redesigning the UI, and prefer small,
reviewable changes that follow these patterns.

Review this AI-generated migration

Use this when you already have a KO→Alpine/HTMX change (maybe generated by AI) and want a second-pass sanity check.

Markdown
Task:

Review this Knockout → Alpine (+HTMX) migration for correctness and small improvements.

Context:

- The old code used Knockout; the new code uses Alpine and/or HTMX.
- The goal is to keep behavior identical while removing Knockout.
- This is in the CommCare HQ codebase (Django, Bootstrap 5, Alpine, HTMX, `HqHtmxActionMixin`).

Please:

- Compare the Knockout version and the migrated version.
- Point out any behavior that might have changed (validation, defaults, events, edge cases).
- Check for:
  - Lost accessibility attributes (`aria-*`, `role`, labels).
  - Changes to required fields or default values.
  - Event handlers that no longer fire (e.g. `change` vs `input`).
  - Potential issues with focus/keyboard navigation (especially with HTMX swaps).
- Suggest small, concrete fixes while keeping the diff as small as possible.

Code to review (old Knockout first, then new Alpine/HTMX):

Step 5: Before / After Checklists

Before merging a Knockout → Alpine (+HTMX) migration, it helps to run through a few quick sanity checks. The goal is simple: same behaviour, less complexity, no surprises.

Before you start refactoring

These are lightweight prep steps that make the migration and review much easier:

  • Capture current behaviour. Take short notes or screenshots/GIFs: what does the page do today? What are the main interactions?
  • Find the Knockout entry points. Locate:
    • where ko.applyBindings is called, and
    • which templates use data-bind or KO comments (<!-- ko if -->, etc.).
  • Identify server-side helpers. Note which Django views, forms, and template tags/filters the page already uses. These are often good candidates for HTMX endpoints later.
  • Decide your scope. Are you doing a “KO → Alpine only” pass for now, or are you also moving state/validation to HTMX in this PR?

Before you merge: behavior & UX checks

Once you've migrated the code, run through these checks to make sure the page still behaves as expected.

  • Basic interactions still work. Click through:
    • create / edit / delete flows,
    • filters, pagination, sorting, tab switches, dialogs, etc.
  • Edge cases still work. Try:
    • invalid input / required fields,
    • cancel / reset flows,
    • empty states and “no results” states.
  • Keyboard navigation feels sane. Check that:
    • you can tab through interactive elements in a sensible order,
    • inline-edit widgets (like the key/value or to-do demos) don’t “lose” focus when HTMX requests fire, and
    • buttons/checkboxes disable correctly while requests are in flight (hx-disabled-elt).
  • Loading and error states are clear.
    • Buttons show a spinner or disabled state when submitting (where appropriate).
    • HTMX errors are surfaced via the shared error modal or a clear inline message (no silent 500s).
  • Translations and template tags survived.
    • No lost {% trans %} / {% blocktrans %} blocks.
    • Template tags, filters, and context variables are still used correctly in the new markup.
  • Analytics / tracking still fire (if present). If the page has GTM events or other tracking, confirm they still fire on the expected interactions.

Before you merge: code & dependency cleanup

These are the “don't leave crumbs behind” checks:

  • No remaining Knockout bindings on this page.
    • No data-bind attributes.
    • No KO comment templates (<!-- ko ... -->).
    • No ko.observable, ko.applyBindings, or KO imports in related JS files.
  • Alpine and/or HTMX wiring is clear.
    • Inline Alpine: x-data, x-model, x-show, x-for, etc., are small and local.
    • Reusable models: Alpine.data("name", ...) or a shared export default (initial) => ({ ... }) module is in place.
    • HTMX: hx-get/hx-post, hx-target, hx-swap, and hq-hx-action all point to real view methods.
  • View and template structure is still sane.
    • Class-based views still inherit from the expected base classes (e.g. BasePageView, TemplateView).
    • If using HqHtmxActionMixin, HTMX handlers are small, focused methods decorated with @hq_hx_action.
  • Tests and linting pass.
    • Any existing tests for this page still pass.
    • If you added new HTMX endpoints or forms, consider at least one small view test or form test that exercises them.
  • Dead code is gone.
    • Old Knockout modules or RequireJS entries used only by this page are removed or flagged for removal.
    • Inline scripts that only existed to bootstrap KO are gone.

Commits from previous migrations

Here is a starting list of migration commits you can refer to when planning or reviewing your own Knockout → Alpine (+HTMX) work. As our patterns improve, please update this section—add better examples, remove outdated ones, and keep the list focused on migrations that reflect our current best practices.

  • Migrating the Stripe card manager
    Shows a reusable Alpine.data model that wraps the Stripe JS API. Useful for:
    • Replacing a Knockout view model that talks to a third-party JS library
    • Keeping all Stripe-specific logic in a dedicated JS module instead of the template
    • Exposing a small, declarative API to the template via x-data / @click
  • Move autopay card management to HTMX + Alpine JS
    Example of moving logic that used to live entirely in JavaScript back into Django views and templates, with HTMX handling updates and Alpine handling light UI state. Patterns to look for:
    • Using HqHtmxActionMixin + hq-hx-action instead of multiple KO endpoints or big conditional blocks
    • Returning small partials to refresh just the “select autopay card” UI
    • Letting Alpine manage only local concerns (toggling loaders, showing/hiding bits of UI)
  • Introducing Alpine to the Billing Information form
    Not really a Knockout to Alpine migration, but an example of layering Alpine on top of a Django form to handle UI-only behavior. Helpful for:
    • Toggling informational messages / hints based on form field state
    • Using a small Alpine.data model alongside crispy-forms output
    • Keeping validation and business rules in Django while Alpine manages visibility and text