Stimulus Real-World Patterns — Forms, Modals, Autocomplete, and More
Stimulus real-world patterns are battle-tested templates for form validation, modals, autocomplete, and autosave — running on your existing server-rendered HTML.
What You’ll Learn
You’ll build production-ready components: a validated form with submit protection, an accessible modal with focus trapping, an autocomplete with debounced search, infinite scroll with IntersectionObserver, and an autosave form — plus testing strategies with Jest.
Why Real-World Patterns Matter
Tutorials teach you the building blocks. Real apps need the finished structure. Without patterns, every developer reinvents the modal, every form leaks event listeners, and every autocomplete has different keyboard behavior.
These patterns come from production apps using Stimulus with Rails, Laravel, and Django. They handle edge cases you’ll encounter on day one: double submissions, memory leaks, empty states, and accessibility.
The Doda Browser extension uses these exact patterns for its settings UI — form validation for configuration panels, modals for confirmation dialogs, autocomplete for bookmark search, and autosave for preference changes.
flowchart LR
A["Form Patterns"] --> B["Validation"]
A --> C["Disable on Submit"]
A --> D["Autosave"]
E["UI Patterns"] --> F["Modal with Focus Trap"]
E --> G["Autocomplete"]
E --> H["Infinite Scroll"]
I["Integration"] --> J["Turbo Frames"]
I --> K["Testing with Jest"]
style A fill:#4f46e5,color:#fff
style E fill:#ea580c,color:#fff
style I fill:#059669,color:#fff
Stimulus vs React vs Alpine.js for Real-World Patterns
| Feature | Stimulus | React | Alpine.js |
|---|---|---|---|
| Form validation | Controller + targets | Formik + Yup | x-validate plugin |
| Modal | Custom controller | Dialog library | x-show + x-transition |
| Autocomplete | Custom controller | Downshift/React-Select | Custom component |
| Infinite scroll | Controller + Observer | Infinite scroll lib | Custom component |
| Testing | Jest + DOM | Testing Library | Limited |
| Server-rendered HTML | Natural | Fights against | Natural |
Choose Stimulus when: Your app is server-rendered and you need to sprinkle JavaScript behavior. You want minimal boilerplate and no build step.
Pattern 1: Form Validation
A reusable form validator that validates on blur and on submit, shows error messages, and prevents submission on invalid data.
application.register('form-validation', class extends Stimulus.Controller {
static targets = ['field', 'error']
static values = {
validateOnBlur: { type: Boolean, default: true },
validateOnSubmit: { type: Boolean, default: true }
}
connect() {
if (this.validateOnBlurValue) {
this.fieldTargets.forEach(field => {
field.addEventListener('blur', () => this.validateField(field))
})
}
}
validate(event) {
event.preventDefault()
let valid = true
this.fieldTargets.forEach(field => {
if (!this.validateField(field)) valid = false
})
if (valid) event.target.submit()
}
validateField(field) {
const error = this.errorTargets.find(e => e.dataset.field === field.name)
const value = field.value.trim()
let message = ''
if (field.required && !value) {
message = `${field.name} is required`
} else if (field.type === 'email' && value && !value.includes('@')) {
message = 'Enter a valid email address'
} else if (field.minLength && value.length < field.minLength) {
message = `Minimum ${field.minLength} characters`
}
field.classList.toggle('invalid', !!message)
if (error) {
error.textContent = message
error.style.display = message ? 'block' : 'none'
}
return !message
}
})<div data-controller="form-validation"
data-action="submit->form-validation#validate">
<input type="email" name="email" required
data-form-validation-target="field">
<div data-form-validation-target="error"
data-field="email" style="display: none;"></div>
<button type="submit">Submit</button>
</div>What’s happening step by step:
- On
connect(), each field target gets ablurlistener for live validation - On form submit,
validate()checks every field validateField()finds the matching error element bydata-fieldattribute- If invalid, sets the error message text and shows it
- If all valid, the form submits naturally
Pattern 2: Disable on Submit
Prevents double form submissions — a classic UX problem where users click Submit twice and create duplicate records.
application.register('disable-on-submit', class extends Stimulus.Controller {
static targets = ['submit']
static values = {
loadingText: { type: String, default: 'Submitting...' }
}
disable(event) {
const submitter = this.submitTarget
if (!submitter || submitter.disabled) return
submitter.disabled = true
submitter.textContent = this.loadingTextValue
}
enable(event) {
const submitter = this.submitTarget
if (!submitter) return
submitter.disabled = false
submitter.textContent = submitter.dataset.originalText || 'Submit'
}
})<div data-controller="disable-on-submit"
data-action="turbo:submit-end->disable-on-submit#enable">
<button type="submit" data-disable-on-submit-target="submit"
data-action="click->disable-on-submit#disable">
Submit
</button>
</div>Why this works: The button is immediately disabled on click. The turbo:submit-end event re-enables it if using Turbo. Otherwise, the form submission naturally reloads the page.
Pattern 3: Accessible Modal
A complete modal with focus trapping, escape-to-close, click-outside-to-close, and body scroll locking. These features are essential for accessibility compliance (WCAG).
application.register('modal', class extends Stimulus.Controller {
static targets = ['container', 'content', 'closeButton']
static values = {
open: { type: Boolean, default: false },
closeOnEscape: { type: Boolean, default: true },
closeOnOutsideClick: { type: Boolean, default: true }
}
connect() {
if (this.openValue) this.openModal()
}
disconnect() {
this.closeModal()
}
open() { this.openValue = true }
close(event) {
if (event && event.target !== event.currentTarget) return
this.openValue = false
}
openValueChanged() {
this.openValue ? this.openModal() : this.closeModal()
}
openModal() {
document.body.classList.add('modal-open')
this.containerTarget.classList.remove('hidden')
this._keyHandler = (e) => {
if (this.closeOnEscapeValue && e.key === 'Escape') this.close()
if (e.key === 'Tab') this._trapTab(e)
}
document.addEventListener('keydown', this._keyHandler)
this._focusFirst()
}
closeModal() {
document.body.classList.remove('modal-open')
this.containerTarget.classList.add('hidden')
document.removeEventListener('keydown', this._keyHandler)
}
_trapTab(event) {
const focusable = this.containerTarget.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
if (!focusable.length) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first.focus()
}
}
_focusFirst() {
const el = this.containerTarget.querySelector(
'button, [href], input, select, textarea'
)
el?.focus()
}
})<div data-controller="modal"
data-modal-close-on-escape-value="true"
data-modal-close-on-outside-click-value="true">
<button data-action="click->modal#open">Open Modal</button>
<div data-modal-target="container" class="hidden"
style="position: fixed; inset: 0; background: rgba(0,0,0,0.5);
display: flex; align-items: center; justify-content: center;">
<div data-modal-target="content"
style="background: white; padding: 2rem; border-radius: 8px; max-width: 400px;">
<h2>Confirm</h2>
<p>Are you sure?</p>
<button data-action="click->modal#close">Cancel</button>
<button data-action="click->modal#close">Confirm</button>
</div>
</div>
</div>What’s happening step by step:
open()setsopenValuetotrue, triggeringopenValueChanged()openModal()locks body scroll, shows container, adds keyboard listener- Tab key cycles through focusable elements (focus trap)
- Escape key or clicking outside the content closes the modal
closeModal()restores everything — unlocks scroll, removes listener, hides container
Pattern 4: Autocomplete with Debounce
A search autocomplete that debounces input, fetches results, handles keyboard navigation, and selects on click.
application.register('autocomplete', class extends Stimulus.Controller {
static targets = ['input', 'results', 'hidden']
static values = {
url: String,
minLength: { type: Number, default: 2 },
delay: { type: Number, default: 300 }
}
initialize() {
this._timer = null
}
connect() {
this.inputTarget.addEventListener('keydown', (e) => this._navigate(e))
}
disconnect() {
this.clearResults()
}
search() {
clearTimeout(this._timer)
const query = this.inputTarget.value.trim()
if (query.length < this.minLengthValue) {
this.clearResults()
return
}
this._timer = setTimeout(() => this._fetch(query), this.delayValue)
}
async _fetch(query) {
try {
const url = new URL(this.urlValue, window.location.origin)
url.searchParams.set('q', query)
const response = await fetch(url, { headers: { 'Accept': 'text/html' } })
const html = await response.text()
this.resultsTarget.innerHTML = html
this.resultsTarget.classList.remove('hidden')
} catch (error) {
console.error('Autocomplete error:', error)
}
}
selectResult(event) {
const item = event.currentTarget
this.inputTarget.value = item.dataset.value || item.textContent
if (this.hasHiddenTarget) {
this.hiddenTarget.value = item.dataset.id || ''
}
this.clearResults()
}
_navigate(event) {
const items = this.resultsTarget.querySelectorAll('[data-autocomplete-target="item"]')
if (!items.length) return
const current = this.resultsTarget.querySelector('.highlighted')
let index = Array.from(items).indexOf(current)
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
index = Math.min(index + 1, items.length - 1)
break
case 'ArrowUp':
event.preventDefault()
index = Math.max(index - 1, 0)
break
case 'Enter':
event.preventDefault()
current?.click()
return
case 'Escape':
this.clearResults()
return
default:
return
}
items.forEach(i => i.classList.remove('highlighted'))
items[index]?.classList.add('highlighted')
}
clearResults() {
this.resultsTarget.innerHTML = ''
this.resultsTarget.classList.add('hidden')
}
})Pattern 5: Autosave Form
Automatically saves form data after a user stops typing — like Google Docs autosave.
application.register('autosave', class extends Stimulus.Controller {
static targets = ['form']
static values = {
saveDelay: { type: Number, default: 2000 }
}
initialize() {
this._timer = null
this._dirty = false
}
connect() {
this.formTarget.addEventListener('input', () => this._markDirty())
}
_markDirty() {
this._dirty = true
clearTimeout(this._timer)
this._timer = setTimeout(() => this.save(), this.saveDelayValue)
}
async save() {
if (!this._dirty) return
const data = new FormData(this.formTarget)
try {
const response = await fetch(this.formTarget.action, {
method: this.formTarget.method,
body: data,
headers: { 'Accept': 'text/html' }
})
if (response.ok) {
this._dirty = false
this.dispatch('saved')
}
} catch (error) {
this.dispatch('save-failed', { detail: { error } })
}
}
})Why autosave matters: Users forget to save. Autosave eliminates data loss. The debounce timer ensures you’re not saving on every keystroke — only after the user pauses typing.
Pattern 6: Infinite Scroll
Loads more content when the user scrolls near the bottom, using IntersectionObserver for performance.
application.register('infinite-scroll', class extends Stimulus.Controller {
static targets = ['container', 'sentinel']
static values = {
page: { type: Number, default: 1 },
loading: { type: Boolean, default: false },
endReached: { type: Boolean, default: false },
rootMargin: { type: String, default: '200px' }
}
connect() {
if (!this.hasSentinelTarget) return
this._observer = new IntersectionObserver(
entries => {
if (entries[0].isIntersecting && !this.loadingValue && !this.endReachedValue) {
this.loadMore()
}
},
{ rootMargin: this.rootMarginValue }
)
this._observer.observe(this.sentinelTarget)
}
disconnect() {
this._observer?.disconnect()
}
async loadMore() {
this.loadingValue = true
try {
const url = new URL(window.location.href)
url.searchParams.set('page', this.pageValue + 1)
const response = await fetch(url, { headers: { 'Accept': 'text/html' } })
const html = await response.text()
const template = document.createElement('template')
template.innerHTML = html
const newItems = template.content.querySelector('[data-infinite-scroll-target="container"]')
if (newItems) {
this.containerTarget.append(...newItems.childNodes)
this.pageValue++
}
} catch (error) {
this.dispatch('load-error', { detail: { error } })
} finally {
this.loadingValue = false
}
}
})Testing with Jest
Stimulus controllers are easy to test because they’re plain JavaScript classes. Set up the DOM, register the controller, and assert.
import { Application, Controller } from '@hotwired/stimulus'
describe('CounterController', () => {
let application
beforeEach(() => {
document.body.innerHTML = `
<div data-controller="counter">
<span data-counter-target="display">0</span>
<button data-action="click->counter#increment">+</button>
</div>
`
application = Application.start()
application.register('counter', class extends Controller {
static targets = ['display']
increment() {
this.displayTarget.textContent =
parseInt(this.displayTarget.textContent) + 1
}
})
})
afterEach(() => { application.stop() })
it('increments on click', () => {
document.querySelector('button').click()
expect(document.querySelector('[data-counter-target="display"]').textContent)
.toBe('1')
})
})Why test controllers? Controllers contain business logic — validation rules, state changes, API calls. Testing them in isolation catches bugs that manual testing misses.
Common Mistakes
1. Not cleaning up IntersectionObservers
// ❌ Observer keeps running after disconnect
connect() {
this._observer = new IntersectionObserver(...)
this._observer.observe(this.element)
}
// disconnect() missing!
// ✅ Always disconnect
disconnect() {
this._observer?.disconnect()
}2. Double form submissions
// ❌ User clicks Submit twice, two records created
// ✅ Disable the button immediately on click
3. Not handling empty states in autocomplete
// ❌ No results: shows loading forever
// ✅ Check response length before showing results
if (items.length === 0) {
this.resultsTarget.innerHTML = '<div class="empty">No results</div>'
}4. Leaking event listeners on window/document
// ❌ Every modal open adds a new listener
connect() {
document.addEventListener('keydown', this.handleEscape)
}
// ✅ Remove in disconnect
disconnect() {
document.removeEventListener('keydown', this.handleEscape)
}5. Forgetting focus trapping in modals
Without focus trapping, keyboard users tab outside the modal and can’t get back. Always cycle focus within the modal container.
6. Autosave saving on every keystroke
// ❌ Saves on every keystroke — hundreds of requests
input.addEventListener('input', () => this.save())
// ✅ Debounce — saves 2 seconds after user stops typing
input.addEventListener('input', () => {
clearTimeout(this._timer)
this._timer = setTimeout(() => this.save(), 2000)
})Practice Questions
1. Why does the modal pattern need focus trapping?
For accessibility. Without it, keyboard users can Tab outside the modal and lose context. Focus trapping keeps focus cycling within modal elements until it’s closed.
2. What’s the purpose of rootMargin: '200px' in infinite scroll?
It triggers loading 200px before the sentinel is actually visible. This gives the network request time to complete before the user reaches the bottom, preventing a visible loading delay.
3. How does debounce work in the autocomplete pattern?
Each keystroke resets a timer. The search only fires after the user stops typing for 300ms. This prevents a network request on every keystroke.
4. Why test controllers with application.stop() in afterEach?
Each test starts a fresh Stimulus application. application.stop() prevents controller instances from the previous test from interfering with the next test.
🏆 Challenge
Build a multi-step form wizard. Each step is a controller that validates its fields, then dispatches an event to advance to the next step. The wizard controller listens for step:completed events and shows/hides panels. Bonus: persist form data to localStorage on each step.
FAQ
Try It Yourself
A complete demo with modal, autocomplete, infinite scroll, and autosave:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stimulus Patterns Demo</title>
<script defer src="https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.x.x/dist/stimulus.umd.min.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; max-width: 700px; margin: 2rem auto; padding: 0 1rem; background: #f8fafc; }
.card { background: white; border-radius: 8px; padding: 1.5rem; margin-bottom: 1.5rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.card h2 { margin: 0 0 1rem 0; font-size: 1rem; text-transform: uppercase; letter-spacing: 0.05em; color: #475569; }
.hidden { display: none !important; }
button { padding: 0.5rem 1rem; border: none; border-radius: 4px; cursor: pointer; }
.btn-primary { background: #3b82f6; color: white; }
input[type="text"] { padding: 0.5rem; border: 1px solid #e2e8f0; border-radius: 4px; width: 200px; }
</style>
</head>
<body>
<h1>Stimulus Patterns Demo</h1>
<!-- Modal -->
<div class="card" data-controller="modal"
data-modal-close-on-escape-value="true"
data-modal-close-on-outside-click-value="true">
<h2>Modal</h2>
<button class="btn-primary" data-action="click->modal#open">Open</button>
<div data-modal-target="container" class="hidden"
style="position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 50;">
<div style="background: white; padding: 2rem; border-radius: 8px; max-width: 400px;">
<h3>Confirm Action</h3>
<p>Are you sure you want to proceed?</p>
<button class="btn-primary" data-action="click->modal#close">Confirm</button>
<button data-action="click->modal#close">Cancel</button>
</div>
</div>
</div>
<!-- Autocomplete -->
<div class="card" data-controller="autocomplete">
<h2>Autocomplete</h2>
<div style="position: relative;">
<input type="text" data-autocomplete-target="input"
data-action="input->autocomplete#search"
placeholder="Type a fruit..." autocomplete="off">
<div data-autocomplete-target="results"
class="hidden"
style="position: absolute; top: 100%; left: 0; right: 0; background: white;
border: 1px solid #e2e8f0; border-radius: 4px; max-height: 200px; overflow-y: auto; z-index: 40;">
</div>
</div>
</div>
<script>
(() => {
const app = Stimulus.Application.start()
const fruits = ['apple','apricot','banana','blackberry','cherry','grape','kiwi','lemon','mango','orange','pear','plum','raspberry','strawberry']
app.register('modal', class extends Stimulus.Controller {
static targets = ['container']
static values = { open: Boolean, closeOnEscape: Boolean, closeOnOutsideClick: Boolean }
connect() { if (this.openValue) this._open() }
disconnect() { this._close() }
open() { this.openValue = true }
close(e) {
if (e && e.target !== this.containerTarget && this.closeOnOutsideClickValue) return
this.openValue = false
}
openValueChanged() { this.openValue ? this._open() : this._close() }
_open() {
this.containerTarget.classList.remove('hidden')
this._keyHandler = (e) => {
if (this.closeOnEscapeValue && e.key === 'Escape') this.close()
}
document.addEventListener('keydown', this._keyHandler)
}
_close() {
this.containerTarget.classList.add('hidden')
document.removeEventListener('keydown', this._keyHandler)
}
})
app.register('autocomplete', class extends Stimulus.Controller {
static targets = ['input', 'results']
static values = { minLength: { type: Number, default: 1 }, delay: { type: Number, default: 200 } }
initialize() { this._timer = null }
search() {
clearTimeout(this._timer)
const q = this.inputTarget.value.toLowerCase().trim()
if (q.length < this.minLengthValue) { this.clearResults(); return }
this._timer = setTimeout(() => {
const matches = fruits.filter(f => f.includes(q)).slice(0, 6)
if (matches.length === 0) {
this.resultsTarget.innerHTML = '<div style="padding: 0.5rem; color: #94a3b8;">No results</div>'
} else {
this.resultsTarget.innerHTML = matches.map(f =>
`<div style="padding: 0.5rem; cursor: pointer; border-bottom: 1px solid #f1f5f9;"
data-action="click->autocomplete#select">${f}</div>`
).join('')
}
this.resultsTarget.classList.remove('hidden')
}, this.delayValue)
}
select(event) {
this.inputTarget.value = event.currentTarget.textContent
this.clearResults()
}
clearResults() {
this.resultsTarget.innerHTML = ''
this.resultsTarget.classList.add('hidden')
}
})
})()
</script>
</body>
</html>Try this: Open the modal (press Escape to close). Type a fruit in the autocomplete (try “ap” or “berry”).
What’s Next
| Topic | Description |
|---|---|
| https://tutorials.dodatech.com/frontend/libraries/stimulus/stimulus-communication/ | Cross-controller communication with events and outlets |
| https://tutorials.dodatech.com/frontend/libraries/stimulus/stimulus-lifecycle-state/ | Lifecycle callbacks and state management |
| Alpine.js Compare | Alpine.js vs Stimulus for reactive UIs |
| HTMX | Server-driven HTML with Stimulus enhancement |
| Jest | Unit testing JavaScript controllers |
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro. These production patterns are the same ones used in the Doda Browser extension — form validation for settings panels, accessible modals for confirmation dialogs, and autocomplete for bookmark and history search.
What’s Next
Congratulations on completing this Stimulus Patterns tutorial! Here’s where to go from here:
- Practice daily — Consistency is more important than long study sessions
- Build a project — Apply what you learned by building something real
- Explore related topics — Check out other tutorials in the same category
- Join the community — Discuss with other learners and share your progress
Remember: every expert was once a beginner. Keep coding!
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro