A fellow developer's guide to building reactive forms without the framework bloat
Introduction
Hey there! π
So I've been on this journey lately to become what I call a "minimalist fullstack developer" β basically trying to build solid web apps with simple, fundamental tools instead of drowning in framework complexity. You know how it is: one day you're adding React for a simple form, next thing you know you've got 47 dependencies and a build process that takes longer than your actual development time.
That's when I discovered HTMX, and honestly? It's been a game-changer for building interactive UIs without the JavaScript framework circus. But today I want to share something specific that took me way too long to figure out: dependent dropdowns.
You know the drill β user selects a country, cities dropdown updates. User picks a category, products filter accordingly. Seems simple, right? Well, I tried about 5 different approaches before finding patterns that actually work well in production.
The Problem That Started It All
I was building an e-commerce filter system. Nothing fancy β just Country β State β City dropdowns for shipping. My first instinct was to reach for React or Vue, but then I remembered my minimalist goals. How hard could it be with HTMX?
Spoiler alert: I made it way harder than it needed to be.
After countless Stack Overflow tabs and some embarrassing production bugs (RIP user focus when dropdowns updated), I finally cracked the code. Here are the 5 strategies I discovered, ranked from "definitely use this" to "please don't make my mistakes."
Strategy 1: Hidden Listeners (My Go-To Now)
This became my favorite after learning it the hard way. The idea is simple: use hidden elements that listen for changes from your main dropdown.
<!-- Your main dropdown -->
<select name="country" id="country">
<option value="usa">United States</option>
<option value="canada">Canada</option>
</select>
<!-- Hidden listener (this is the magic) -->
<div hx-trigger="change from:#country"
hx-get="/api/cities"
hx-target="#cities-container"
hx-include="[name='country']"
style="display: none;"></div>
<!-- Where cities will appear -->
<div id="cities-container">
<select name="city" disabled>
<option>Select country first</option>
</select>
</div>
And the Node.js backend is refreshingly simple:
app.post('/api/cities', (req, res) => {
const { country } = req.body;
const cities = getCitiesForCountry(country);
const options = cities.map(city =>
`<option value="${city.code}">${city.name}</option>`
).join('');
res.send(`
<select name="city">
<option value="">Choose city...</option>
${options}
</select>
`);
});
Why I love this approach:
- Clean separation: the dropdown does dropdown things, the listener does listening things
- User focus stays exactly where it should be
- Easy to debug when things go wrong (and they will)
- Scales beautifully when you need 5+ dependent dropdowns
The only downside: Multiple HTTP requests. But honestly? Unless you're building the next Amazon, this won't be your bottleneck.
Strategy 2: Multiple Targets (Good for Simple Cases)
When I just need basic Country β City dependency, sometimes I go with the straightforward approach:
<select name="country"
hx-get="/api/cities"
hx-trigger="change"
hx-target="#cities-container"
hx-include="[name='country']">
<option value="usa">United States</option>
</select>
<div id="cities-container">
<select name="city">...</select>
</div>
Perfect for: Contact forms, simple address inputs, anything with 2-3 dropdowns max.
Skip it when: You have complex interdependencies or more than 3 levels of cascading.
Strategy 3: OOB Swaps (When Performance Matters)
This one took me a while to wrap my head around, but it's brilliant for high-traffic applications. One request updates multiple dropdowns simultaneously using HTMX's "out-of-band" swaps.
<select name="country"
hx-get="/api/update-all"
hx-trigger="change"
hx-target="this"
hx-include="[name='country']">
</select>
<div id="cities-container">
<select name="city">...</select>
</div>
<div id="products-container">
<select name="product">...</select>
</div>
Backend magic:
app.post('/api/update-all', (req, res) => {
const { country } = req.body;
// Main response updates the country dropdown
let response = `<option value="${country}" selected>${getCountryName(country)}</option>`;
// These update other dropdowns "out-of-band"
response += `
<div id="cities-container" hx-swap-oob="true">
<select name="city">
${renderCityOptions(country)}
</select>
</div>
<div id="products-container" hx-swap-oob="true">
<select name="product">
${renderProductOptions(country)}
</select>
</div>`;
res.send(response);
});
When to use this: High-traffic sites, complex forms where everything depends on everything else, when you want to minimize server round-trips.
Learning curve warning: OOB swaps can be tricky to debug. Start with hidden listeners, graduate to this when you need the performance boost.
Strategy 4: Event Chaining (For Complex Logic)
Sometimes you need conditional updates or complex business logic. That's when I break out the JavaScript:
<select name="country" id="country"
hx-get="/api/countries"
hx-trigger="change"
hx-target="this">
</select>
<script>
htmx.on('htmx:afterSwap', function(evt) {
if (evt.target.id === 'country') {
const country = evt.target.value;
// Update cities
htmx.ajax('GET', '/api/cities', {
source: '#country',
target: '#cities-container'
});
// Maybe update products, but only for certain countries
if (['usa', 'canada'].includes(country)) {
htmx.ajax('GET', '/api/products', {
source: '#country',
target: '#products-container'
});
}
}
});
</script>
Perfect for: Multi-step wizards, forms with conditional fields, when business logic gets complex.
Use sparingly: More JavaScript means more complexity. I only reach for this when the other strategies can't handle my requirements.
Strategy 5: Form Replacement (Please Don't)
I'm including this because I made this mistake early on, and I want to save you the pain:
<!-- DON'T DO THIS -->
<form id="myform">
<select name="country"
hx-get="/update-form"
hx-trigger="change"
hx-target="#myform"
hx-swap="outerHTML">
</select>
</form>
Why this is terrible:
- User loses focus every time (super annoying)
- Form state gets wiped out
- Accessibility nightmare
- Mobile users will hate you
I learned this lesson when a user complained they couldn't fill out our address form on mobile. Turns out, every time they selected a country, the keyboard would disappear because focus was lost. Oops.
Decision Tree (Save Yourself Some Time)
Here's how I decide which strategy to use now:
- Building something new? β Start with Hidden Listeners
- Simple 2-dropdown form? β Multiple Targets is fine
- High-traffic application? β OOB Swaps for performance
- Complex conditional logic? β Event Chaining (but prepare for debugging sessions)
- Form replacement? β Just don't. Seriously.
Real-World Examples from My Projects
-
E-commerce product filters: Hidden Listeners
- Multiple independent filters, clean separation of concerns
-
Address forms: OOB Swaps
- Performance critical, single atomic update
-
Contact form with country/state: Multiple Targets
- Only 2 dropdowns, keep it simple
-
Multi-step onboarding wizard: Event Chaining
- Complex conditional flows, different paths for different user types
The Backend Security Stuff (Don't Skip This)
Since we're dealing with user input, here's what I learned about keeping things secure:
const { body, validationResult } = require('express-validator');
// Validate everything
const validateCountry = [
body('country')
.isAlpha()
.isLength({ min: 2, max: 3 })
.withMessage('Invalid country code')
];
app.post('/api/cities', validateCountry, (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).send(`
<div class="error">Invalid request</div>
`);
}
// Safe to process...
});
// Always escape HTML
function escapeHtml(unsafe) {
return unsafe
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">");
}
Also, rate limiting is your friend:
const rateLimit = require('express-rate-limit');
const dropdownLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 200 // reasonable for dropdown interactions
});
app.use('/api', dropdownLimiter);
Performance Tips I Wish I Knew Earlier
Use delays for search inputs:
hx-trigger="input delay:300ms"
Cache the obvious stuff:
Country/state data doesn't change often. Cache it.
Keep responses small:
Return only the HTML you need, not entire page fragments.
Consider CDN caching:
Static dropdown options (countries, states) are perfect for CDN caching.
Wrapping Up
HTMX has genuinely changed how I think about building interactive web apps. No build step, no giant bundle sizes, no fighting with state management β just HTML that talks to your server when it needs to.
The dependent dropdown patterns I've shared here work in production. I've used them across different projects, from simple contact forms to complex e-commerce filters. Start with hidden listeners, and you'll probably find they solve 80% of your use cases.
The best part? When you need to hand off code to another developer, they can read the HTML and immediately understand what's happening. No documentation about component lifecycle methods or state management patterns. Just: "When this changes, update that."
That's the kind of simplicity I'm after in my minimalist fullstack journey.
Have you tried HTMX for dependent dropdowns? I'd love to hear about your experiences β especially if you've found patterns I haven't considered yet. Drop a comment below or reach out on Twitter!
Want more HTMX tips? I'm planning a series on building full-stack applications with minimal dependencies. Follow me for updates, and let me know what topics you'd like me cover next.
Top comments (0)