Introduction: Building Dynamic Data Displays
Welcome to Chapter 16! In our previous projects, we’ve explored the fundamental power of HTMX to fetch and swap HTML fragments. Now, we’re going to level up by building a truly interactive and dynamic feature: a real-time search and filter interface. This is a common requirement for almost any modern web application that displays lists of data, from product catalogs to user directories.
By the end of this chapter, you’ll have built a fully functional interface where users can type into a search box or select options from a filter dropdown, and the displayed list of items will update instantly without a full page reload. This project will solidify your understanding of hx-get, hx-trigger, hx-target, and hx-swap, and introduce you to handling multiple input parameters dynamically. Get ready to make your web applications feel incredibly responsive and user-friendly!
Before we dive in, make sure you’re comfortable with:
- Including HTMX in your project.
- Basic
hx-getrequests to fetch HTML. - Understanding
hx-targetandhx-swapfor controlling where and how content updates. - A basic understanding of server-side rendering (we’ll use a simple Flask backend for this example).
Let’s get started!
Core Concepts: The Magic Behind Real-time Updates
Creating a real-time search and filter with HTMX hinges on a few key ideas:
1. Event-Driven Requests: hx-trigger="keyup changed"
Traditionally, if you wanted to search as a user types, you’d write JavaScript to listen for keyup events, debounce them, and then make an AJAX request. HTMX simplifies this immensely with hx-trigger.
keyup: This event fires every time a key is released. If we used justkeyup, it would send a request on every single keystroke, which can be excessive.changed: This modifier tells HTMX to only send a request if the value of the input has actually changed since the last request. This is crucial for forms and inputs where the user might press an arrow key or modifier key without altering the text.delay:300ms: This is a powerful addition. It tells HTMX to wait for 300 milliseconds of inactivity after akeyup changedevent before firing the request. This is a form of debouncing, preventing a flood of requests as the user types quickly. It’s a best practice for search inputs.
So, hx-trigger="keyup changed delay:300ms" means “send a GET request to the specified URL when the input’s value changes, but wait 300ms after the last keystroke before doing so.” Pretty neat, right?
2. Targeting and Swapping Specific Content: hx-target and hx-swap
When your search or filter input triggers a request, the server will send back a new list of items. You don’t want to reload the entire page; you just want to replace the old list with the new one.
hx-target="#my-item-list": This attribute tells HTMX which element in your current page should be updated. It usually points to an ID of a container that holds the dynamic content.hx-swap="outerHTML"orhx-swap="innerHTML": This determines how the new content replaces the old.outerHTMLreplaces the target element itself with the new content.innerHTMLreplaces only the contents of the target element, keeping the target element’s tag intact. For a list of items,innerHTMLis often appropriate if the target is the<ul>or<div>that contains the list items. If the target is a wrapperdivthat you want to completely replace,outerHTMLmight be used. We’ll useinnerHTMLfor our list.
3. Sending Input Values Automatically
One of HTMX’s most convenient features is how it automatically includes the values of inputs within the “parent” form or even just inputs on the page when a request is triggered.
When an element with hx-get (or hx-post) triggers a request, HTMX will look for other input elements within its “scope” (often its parent form, or just other inputs on the page if they have name attributes) and include their name=value pairs as query parameters (for GET) or form data (for POST). This means you don’t need to manually collect values from your search box and filter dropdown; HTMX handles it for you!
4. Server-Side Rendering of Partial HTML
Remember, HTMX is all about “HTML over the wire.” This means your backend isn’t sending JSON; it’s sending back actual HTML fragments. For our search and filter, the server will:
- Receive the search query and filter parameters (e.g.,
?search=apple&category=fruit). - Query its data source (a simple Python list in our case).
- Filter the data based on the received parameters.
- Render a partial HTML template (e.g., just the
<ul>or<div>containing the filtered items) and send it back.
Let’s put these concepts into practice!
Step-by-Step Implementation
We’ll use a simple Python Flask backend to demonstrate this. If you’re using another framework (Django, FastAPI, Node.js Express, etc.), the HTMX concepts remain the same; you’ll just adapt the backend code.
Setup Your Project
First, let’s create our project structure.
Create a project directory:
mkdir htmx-realtime-search cd htmx-realtime-searchSet up a virtual environment (recommended):
python -m venv venv # On macOS/Linux: source venv/bin/activate # On Windows: .\venv\Scripts\activateInstall Flask and HTMX: We need Flask for the backend and we’ll download HTMX.
pip install Flask==3.0.3 # Latest stable Flask as of 2025-12-04Create
app.py: This will be our Flask application.# htmx-realtime-search/app.py from flask import Flask, render_template, request app = Flask(__name__) # Our sample data PRODUCTS = [ {"id": 1, "name": "Apple", "category": "Fruit", "price": 1.00}, {"id": 2, "name": "Banana", "category": "Fruit", "price": 0.50}, {"id": 3, "name": "Carrot", "category": "Vegetable", "price": 0.75}, {"id": 4, "name": "Milk", "category": "Dairy", "price": 3.00}, {"id": 5, "name": "Cheddar Cheese", "category": "Dairy", "price": 5.50}, {"id": 6, "name": "Broccoli", "category": "Vegetable", "price": 1.20}, {"id": 7, "name": "Orange Juice", "category": "Beverage", "price": 2.75}, {"id": 8, "name": "Yogurt", "category": "Dairy", "price": 1.50}, {"id": 9, "name": "Strawberry", "category": "Fruit", "price": 2.20}, ] @app.route('/') def index(): return render_template('index.html', products=PRODUCTS) if __name__ == '__main__': app.run(debug=True)- Explanation:
- We import
Flask,render_template(to serve HTML files), andrequest(to access incoming request data). app = Flask(__name__)initializes our Flask application.PRODUCTSis our simple list of dictionaries, representing items we want to search and filter.- The
@app.route('/')decorator makes theindexfunction handle requests to the root URL (/). render_template('index.html', products=PRODUCTS)tells Flask to findindex.htmlin atemplatesfolder (which we’ll create next) and pass ourPRODUCTSdata to it.app.run(debug=True)starts the development server.debug=Trueis great for development as it provides auto-reloading and helpful error messages. Remember to turn this off in production!
- We import
- Explanation:
Create a
templatesdirectory:mkdir templatesDownload HTMX (v1.9.11 as of 2025-12-04): Open your browser and navigate to the official HTMX GitHub releases: https://github.com/bigskysoftware/htmx/releases. Look for the latest stable release (as of this guide, we’ll assume
v1.9.11is the latest stable, but always check the official releases page for the absolute latest). Download thehtmx.min.jsfile (orhtmx.jsif you prefer the unminified version for debugging). Place it in astaticdirectory:mkdir static # Save htmx.min.js into the static folder # Example command if you use curl: # curl -o static/htmx.min.js https://unpkg.com/[email protected]/dist/htmx.min.jsNote: For production, it’s often recommended to use a CDN, but for local development, serving it statically is fine. For HTMX
v1.9.11, a CDN link would behttps://unpkg.com/[email protected]/dist/htmx.min.js.
Step 1: The Initial Page (index.html)
Let’s create our main HTML file that will display the product list.
<!-- htmx-realtime-search/templates/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Real-time Product Search with HTMX</title>
<!-- Basic styling for readability -->
<style>
body { font-family: sans-serif; margin: 2em; line-height: 1.6; }
.container { max-width: 800px; margin: 0 auto; }
.product-list { list-style: none; padding: 0; }
.product-item { background: #f9f9f9; border: 1px solid #ddd; margin-bottom: 0.5em; padding: 0.8em; display: flex; justify-content: space-between; align-items: center; }
.product-item strong { color: #333; }
.product-item span { font-size: 0.9em; color: #666; }
input[type="text"], select { padding: 0.5em; margin-right: 1em; border: 1px solid #ccc; border-radius: 4px; }
.controls { margin-bottom: 1.5em; display: flex; align-items: center; }
.loading-indicator { display: none; margin-left: 1em; color: #007bff; }
.htmx-request .loading-indicator { display: inline-block; } /* Show when HTMX request is active */
</style>
<!-- Include HTMX library -->
<script src="{{ url_for('static', filename='htmx.min.js') }}"></script>
</head>
<body>
<div class="container">
<h1>Our Awesome Products</h1>
<div class="controls">
<!-- Search input will go here -->
<!-- Filter dropdown will go here -->
<span class="loading-indicator">Loading...</span>
</div>
<div id="product-list-container">
<!-- Initial list of products will be rendered here -->
<ul class="product-list">
{% for product in products %}
<li class="product-item">
<strong>{{ product.name }}</strong>
<span>Category: {{ product.category }} | Price: ${{ "%.2f"|format(product.price) }}</span>
</li>
{% else %}
<li class="product-item">No products found.</li>
{% endfor %}
</ul>
</div>
</div>
</body>
</html>
- Explanation:
- This is a standard HTML5 document.
- We include some basic CSS for better presentation.
<!-- Include HTMX library -->points to our downloadedhtmx.min.jsusing Flask’surl_forhelper. This is essential for HTMX to work!- We have a
<h1>for the title. - A
divwithid="product-list-container"is crucial. This is where our dynamic product list will live and be updated by HTMX. - Inside
product-list-container, we use a Jinja2forloop (Flask’s templating engine) to display the initialproductsdata passed fromapp.py. - We also added a
loading-indicatorspan and some CSS. Thehtmx-requestclass is automatically added to thebody(or the element making the request, orbodyby default) when an HTMX request is active, allowing us to show/hide a loading message.
Go ahead and run your Flask app now:
python app.py
Then open your browser to http://127.0.0.1:5000/. You should see the list of products.
Step 2: Create a Partial Template for the Product List
When HTMX requests new data, the server will only send back the <ul> containing the products, not the entire index.html. Let’s create a separate template for just this part.
Create a new file templates/product_list_partial.html:
<!-- htmx-realtime-search/templates/product_list_partial.html -->
<ul class="product-list">
{% for product in products %}
<li class="product-item">
<strong>{{ product.name }}</strong>
<span>Category: {{ product.category }} | Price: ${{ "%.2f"|format(product.price) }}</span>
</li>
{% else %}
<li class="product-item">No products found.</li>
{% endfor %}
</ul>
- Explanation: This file contains only the
<ul>and its<li>items. It’s designed to be a reusable fragment.
Step 3: Add the Search Input and Backend Endpoint
Now for the HTMX magic! We’ll add a search input to index.html and a new endpoint to app.py to handle the search.
Modify
app.py: Add a new routesearch_products.# htmx-realtime-search/app.py (updated) from flask import Flask, render_template, request app = Flask(__name__) PRODUCTS = [ {"id": 1, "name": "Apple", "category": "Fruit", "price": 1.00}, {"id": 2, "name": "Banana", "category": "Fruit", "price": 0.50}, {"id": 3, "name": "Carrot", "category": "Vegetable", "price": 0.75}, {"id": 4, "name": "Milk", "category": "Dairy", "price": 3.00}, {"id": 5, "name": "Cheddar Cheese", "category": "Dairy", "price": 5.50}, {"id": 6, "name": "Broccoli", "category": "Vegetable", "price": 1.20}, {"id": 7, "name": "Orange Juice", "category": "Beverage", "price": 2.75}, {"id": 8, "name": "Yogurt", "category": "Dairy", "price": 1.50}, {"id": 9, "name": "Strawberry", "category": "Fruit", "price": 2.20}, ] @app.route('/') def index(): return render_template('index.html', products=PRODUCTS) # NEW: Endpoint for searching and filtering @app.route('/search_products') def search_products(): search_query = request.args.get('search', '').lower() # Filter products based on search query filtered_products = [ p for p in PRODUCTS if search_query in p['name'].lower() ] # Render the partial template with the filtered products return render_template('product_list_partial.html', products=filtered_products) if __name__ == '__main__': app.run(debug=True)- Explanation of
search_productsendpoint:@app.route('/search_products'): This defines a new URL path that HTMX will request.search_query = request.args.get('search', '').lower(): We userequest.args.get('search')to safely retrieve the value of a query parameter namedsearch. If it’s not present, it defaults to an empty string. We convert it to lowercase for case-insensitive searching.filtered_products = [...]: A simple list comprehension to filterPRODUCTSwhere the product name contains thesearch_query.return render_template('product_list_partial.html', products=filtered_products): This is key! We render our partial template (product_list_partial.html) with only the filtered products. HTMX will then take this HTML and swap it into the page.
- Explanation of
Modify
index.html: Add the search input. Locate the<!-- Search input will go here -->comment inindex.htmland replace it with:<!-- htmx-realtime-search/templates/index.html (partial update) --> ... <div class="controls"> <input type="text" name="search" placeholder="Search products..." hx-get="/search_products" hx-trigger="keyup changed delay:300ms" hx-target="#product-list-container" hx-swap="innerHTML" class="search-input"> <!-- Filter dropdown will go here --> <span class="loading-indicator">Loading...</span> </div> ...- Explanation of new attributes:
name="search": This is crucial! HTMX will use thisnameattribute to send the input’s value as a query parameter (e.g.,?search=apple) to the/search_productsendpoint.hx-get="/search_products": This tells HTMX to make aGETrequest to our new Flask endpoint when triggered.hx-trigger="keyup changed delay:300ms": This is our real-time trigger! It will fire a request 300ms after the user stops typing, but only if the input value has changed.hx-target="#product-list-container": This tells HTMX that the HTML returned from/search_productsshould replace the content inside the element withid="product-list-container".hx-swap="innerHTML": Specifies that only the inner HTML of#product-list-containershould be replaced, leaving thedivitself intact.
- Explanation of new attributes:
Restart your Flask app (Ctrl+C then python app.py).
Now, open your browser to http://127.0.0.1:5000/ and start typing in the search box. You should see the product list update in real-time! Notice the “Loading…” indicator briefly appearing.
Step 4: Add a Filter Dropdown
Let’s extend this to include a category filter.
Modify
app.py: Update thesearch_productsendpoint to also handle acategoryparameter.# htmx-realtime-search/app.py (updated again) from flask import Flask, render_template, request app = Flask(__name__) PRODUCTS = [ {"id": 1, "name": "Apple", "category": "Fruit", "price": 1.00}, {"id": 2, "name": "Banana", "category": "Fruit", "price": 0.50}, {"id": 3, "name": "Carrot", "category": "Vegetable", "price": 0.75}, {"id": 4, "name": "Milk", "category": "Dairy", "price": 3.00}, {"id": 5, "name": "Cheddar Cheese", "category": "Dairy", "price": 5.50}, {"id": 6, "name": "Broccoli", "category": "Vegetable", "price": 1.20}, {"id": 7, "name": "Orange Juice", "category": "Beverage", "price": 2.75}, {"id": 8, "name": "Yogurt", "category": "Dairy", "price": 1.50}, {"id": 9, "name": "Strawberry", "category": "Fruit", "price": 2.20}, ] @app.route('/') def index(): # Get unique categories for the filter dropdown categories = sorted(list(set(p['category'] for p in PRODUCTS))) return render_template('index.html', products=PRODUCTS, categories=categories) @app.route('/search_products') def search_products(): search_query = request.args.get('search', '').lower() selected_category = request.args.get('category', 'all').lower() # NEW: Get category current_products = PRODUCTS # Apply search filter if search_query: current_products = [ p for p in current_products if search_query in p['name'].lower() ] # NEW: Apply category filter if selected_category and selected_category != 'all': current_products = [ p for p in current_products if selected_category == p['category'].lower() ] return render_template('product_list_partial.html', products=current_products) if __name__ == '__main__': app.run(debug=True)- Explanation of
app.pychanges:- In
index(): We now pass acategorieslist to the template, which will be used to populate the dropdown.set()helps get unique categories, andsorted()orders them. - In
search_products():selected_category = request.args.get('category', 'all').lower(): We retrieve thecategoryparameter from the request. If it’s not present, it defaults to'all'.- We apply the
search_queryfilter first. - Then, we apply the
categoryfilter on the already filtered products. This ensures both filters work together.
- In
- Explanation of
Modify
index.html: Add the filter dropdown. Locate the<!-- Filter dropdown will go here -->comment inindex.htmland replace it with:<!-- htmx-realtime-search/templates/index.html (partial update) --> ... <div class="controls"> <input type="text" name="search" placeholder="Search products..." hx-get="/search_products" hx-trigger="keyup changed delay:300ms" hx-target="#product-list-container" hx-swap="innerHTML" class="search-input"> <select name="category" hx-get="/search_products" hx-trigger="change" hx-target="#product-list-container" hx-swap="innerHTML" class="category-select"> <option value="all">All Categories</option> {% for category in categories %} <option value="{{ category | lower }}">{{ category }}</option> {% endfor %} </select> <span class="loading-indicator">Loading...</span> </div> ...- Explanation of new dropdown:
name="category": Just like withsearch, thisnameattribute is what HTMX uses to send the selected value to the backend.hx-get="/search_products": This dropdown also targets the same backend endpoint. This is the beauty of HTMX’s automatic parameter sending! When the dropdown makes a request, it will send both its owncategoryvalue AND the currentsearchinput’s value to the server.hx-trigger="change": The request fires when the selected option in the dropdown changes.hx-target="#product-list-container"andhx-swap="innerHTML": Same as the search input, we want to update the product list.- The
optiontags are dynamically generated using Jinja2, including an “All Categories” option.
- Explanation of new dropdown:
Restart your Flask app. Now, you can type in the search box and select categories, and the list will update dynamically, combining both filters!
๐ You’ve done it!
You’ve successfully built a sophisticated real-time search and filter interface with minimal JavaScript, leveraging HTMX’s powerful attributes. This pattern is incredibly flexible and forms the basis for many dynamic web features.
Mini-Challenge: Add a Sort-By Feature
Ready for a small challenge to reinforce your learning?
Challenge: Add a “Sort By” dropdown next to your search and filter inputs. This dropdown should allow users to sort the products by Name (alphabetical) or Price (ascending).
Hints:
- Add a
<select>element to yourindex.htmlin the.controlsdiv. - Give it a
nameattribute (e.g.,sort_by) andhx-trigger="change". - Point its
hx-getto the same/search_productsendpoint. - Add
optiontags for “Name” and “Price” (and perhaps “Default” or “None”). - In your
app.py, modify thesearch_productsendpoint:- Retrieve the
sort_byparameter fromrequest.args. - Before rendering the template, apply sorting to
current_productsbased on thesort_byvalue. Python’slist.sort()orsorted()function with akeyargument will be very useful here. - Remember to handle default sorting if no
sort_byis selected.
- Retrieve the
Give it a try! You’ve got all the tools you need.
Need a little nudge? Click for a hint!
Backend Hint: In app.py, you might add something like this after filtering:
# ... after applying search and category filters to current_products ...
sort_by = request.args.get('sort_by', 'name').lower() # Default to sorting by name
if sort_by == 'name':
current_products.sort(key=lambda p: p['name'].lower())
elif sort_by == 'price':
current_products.sort(key=lambda p: p['price'])
Frontend Hint: In index.html, for your new select element:
<select name="sort_by" hx-get="/search_products" hx-trigger="change" hx-target="#product-list-container" hx-swap="innerHTML">
<option value="name">Sort by Name</option>
<option value="price">Sort by Price</option>
</select>
Remember to restart your Flask app after making changes!
Common Pitfalls & Troubleshooting
Even with HTMX simplifying things, here are a few common issues you might encounter:
Returning Full HTML Pages Instead of Fragments:
- Mistake: Your backend endpoint (e.g.,
/search_products) returns the entireindex.htmlstructure (head, body, etc.) instead of just theproduct_list_partial.html. - Symptom: The entire page reloads, or the console shows errors about unexpected HTML.
- Fix: Ensure your HTMX-targeted backend routes only render and return the partial HTML fragment that
hx-targetis expecting. - Modern Best Practice: Always design your HTMX endpoints to return minimal, targeted HTML.
- Mistake: Your backend endpoint (e.g.,
Incorrect
hx-targetorhx-swap:- Mistake: You’ve pointed
hx-targetto an ID that doesn’t exist, orhx-swapis set incorrectly (e.g.,outerHTMLwheninnerHTMLis desired). - Symptom: Nothing updates, or the entire target element disappears, or the new content appears in the wrong place. Check your browser’s developer console for errors.
- Fix: Double-check that the
idinhx-targetexactly matches an element on your page. Experiment withinnerHTMLvs.outerHTMLto get the desired replacement behavior.
- Mistake: You’ve pointed
Backend Not Receiving Parameters:
- Mistake: Your
inputorselectelements don’t have anameattribute, or the backend is looking for a different parameter name (e.g.,request.args.get('query')when the input’s name issearch). - Symptom: Search/filter doesn’t work, and your backend logs show empty or incorrect parameter values.
- Fix: Ensure every input element that needs to send data has a
nameattribute, and that your backend code correctly retrieves that parameter name (e.g.,request.args.get('the_name_attribute_value')).
- Mistake: Your
No
htmx.min.jsLoaded:- Mistake: You forgot to include
<script src="path/to/htmx.min.js"></script>in yourindex.html, or the path is incorrect. - Symptom: No HTMX attributes work at all. No requests are made.
- Fix: Verify the script tag is present in the
<head>or at the end of<body>, and that thesrcpath is correct and accessible. Always check your browser’s network tab to confirmhtmx.min.jsloads successfully.
- Mistake: You forgot to include
Summary
Congratulations! You’ve just completed a significant project that showcases the power of HTMX for creating dynamic, real-time user interfaces.
Here’s what we covered in this chapter:
- Real-time Interaction: How to use
hx-trigger="keyup changed delay:300ms"on inputs for instant feedback. - Combined Filters: How HTMX automatically sends values from multiple inputs (search, filter, sort) to a single backend endpoint.
- Targeted Updates: Reinforcing the use of
hx-targetandhx-swap="innerHTML"to precisely update portions of your page. - Partial Rendering: The crucial concept of backend endpoints returning HTML fragments specifically designed for HTMX to swap in.
- Backend Integration: A practical example using Flask to handle incoming parameters, filter data, and render partial templates.
- Best Practices: Including loading indicators and debouncing for a smooth user experience.
This project is a cornerstone for building many interactive features without writing complex JavaScript. You’re truly beginning to master the “hypermedia as the engine of application state” paradigm!
What’s Next?
In the next chapter, we’ll continue to build on our project by exploring more advanced HTMX features that enhance user experience, such as optimistic UI updates and managing client-side state with HTMX extensions. Get ready to add even more polish to your HTMX applications!