Skip to content
Stimulus Real-World Patterns — Forms, Modals, Autocomplete, and More

Stimulus Real-World Patterns — Forms, Modals, Autocomplete, and More

DodaTech Updated Jun 6, 2026 13 min read

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
  
Prerequisites: Complete all previous tutorials — https://tutorials.dodatech.com/frontend/libraries/stimulus/stimulus-getting-started/, https://tutorials.dodatech.com/frontend/libraries/stimulus/stimulus-lifecycle-state/, and https://tutorials.dodatech.com/frontend/libraries/stimulus/stimulus-communication/. You need solid understanding of controllers, targets, values, lifecycle, and cross-controller events. CSS knowledge and familiarity with the JavaScript are assumed.

Stimulus vs React vs Alpine.js for Real-World Patterns

FeatureStimulusReactAlpine.js
Form validationController + targetsFormik + Yupx-validate plugin
ModalCustom controllerDialog libraryx-show + x-transition
AutocompleteCustom controllerDownshift/React-SelectCustom component
Infinite scrollController + ObserverInfinite scroll libCustom component
TestingJest + DOMTesting LibraryLimited
Server-rendered HTMLNaturalFights againstNatural

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:

  1. On connect(), each field target gets a blur listener for live validation
  2. On form submit, validate() checks every field
  3. validateField() finds the matching error element by data-field attribute
  4. If invalid, sets the error message text and shows it
  5. 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:

  1. open() sets openValue to true, triggering openValueChanged()
  2. openModal() locks body scroll, shows container, adds keyboard listener
  3. Tab key cycles through focusable elements (focus trap)
  4. Escape key or clicking outside the content closes the modal
  5. 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

How do I handle form validation with Turbo?

Stimulus controllers work seamlessly with Turbo. Use turbo:submit-end events to re-enable forms after Turbo submissions. Controllers automatically reconnect when Turbo replaces content.

Can I use Stimulus with React/Vue components?

Yes. Stimulus can wrap React or Vue components as child elements. Use Stimulus for the outer behavior and let React/Vue manage their own internal state within a Stimulus target element.

How do I test controllers that make fetch requests?

Use Jest’s fetch mocking or sinon to stub network requests. Trigger the controller method and assert that the correct URL was called with the right parameters.

What’s the best way to share code between controllers?

Extract shared logic into separate JavaScript modules that can be imported into multiple controllers. Use composition rather than inheritance.

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

TopicDescription
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 CompareAlpine.js vs Stimulus for reactive UIs
HTMXServer-driven HTML with Stimulus enhancement
JestUnit 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