Rails Stimulus e Importmaps en 2026: JavaScript Moderno Sin Herramientas de Build

Guía completa sobre Rails Stimulus e Importmaps en 2026. Aprende a crear aplicaciones JavaScript modernas sin webpack ni bundlers complejos usando Hotwire.

Rails Stimulus e Importmaps en 2026: JavaScript Moderno Sin Herramientas de Build

El desarrollo JavaScript tradicionalmente ha requerido configuraciones complejas, bundlers sofisticados y tiempos de compilación prolongados. Rails 8 en 2026 ofrece un enfoque radicalmente diferente con Stimulus e Importmaps, permitiendo crear aplicaciones interactivas sin ninguna herramienta de build.

Stimulus e Importmaps vienen incluidos por defecto en Rails 8. Esta combinación permite desarrollar interfaces ricas manteniendo la simplicidad que caracteriza la filosofía de Ruby on Rails.

Entendiendo Importmaps en Rails 8

Importmaps es una tecnología nativa de los navegadores modernos que permite importar módulos JavaScript directamente mediante URLs, sin necesidad de compilación previa. Rails integra esta funcionalidad de manera transparente.

El archivo de configuración se encuentra en la raíz del proyecto:

ruby
# config/importmap.rb
pin "application"
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin_all_from "app/javascript/controllers", under: "controllers"

Esta configuración indica a Rails qué módulos JavaScript están disponibles y dónde encontrarlos. El comando pin_all_from resulta particularmente útil ya que registra automáticamente todos los controladores Stimulus presentes en la carpeta especificada.

Para agregar una biblioteca externa desde un CDN, se utiliza el siguiente comando:

bash
bin/importmap pin lodash

Rails descarga automáticamente el paquete desde jspm.io y lo agrega al archivo de configuración.

Arquitectura de Stimulus

Stimulus se basa en un concepto simple pero potente: los controladores. Cada controlador es una clase JavaScript que se conecta al HTML mediante atributos data. Este enfoque mantiene una separación clara entre el comportamiento y la presentación.

A continuación se muestra la estructura de un controlador Stimulus estándar:

app/javascript/controllers/search_controller.jsjavascript
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["input", "results", "loading"]
  static values = {
    url: String,
    debounce: { type: Number, default: 300 }
  }

  connect() {
    console.log("Search controller connected")
  }

  search() {
    clearTimeout(this.timeout)
    this.timeout = setTimeout(() => {
      this.performSearch()
    }, this.debounceValue)
  }

  async performSearch() {
    const query = this.inputTarget.value
    if (query.length < 2) return

    this.loadingTarget.classList.remove("hidden")

    try {
      const response = await fetch(`${this.urlValue}?q=${encodeURIComponent(query)}`)
      const html = await response.text()
      this.resultsTarget.innerHTML = html
    } finally {
      this.loadingTarget.classList.add("hidden")
    }
  }
}

El HTML correspondiente utiliza atributos data para establecer la conexión:

erb
<div data-controller="search" data-search-url-value="/search">
  <input
    type="text"
    data-search-target="input"
    data-action="input->search#search"
    placeholder="Buscar..."
  />
  <div data-search-target="loading" class="hidden">Cargando...</div>
  <div data-search-target="results"></div>
</div>

Manejo de Eventos con Actions

El sistema de acciones de Stimulus permite vincular eventos DOM a métodos de los controladores de manera declarativa. La sintaxis sigue el formato evento->controlador#método.

app/javascript/controllers/form_controller.jsjavascript
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["submit", "error"]
  static values = { submitting: Boolean }

  connect() {
    this.submittingValue = false
  }

  async submit(event) {
    event.preventDefault()
    if (this.submittingValue) return

    this.submittingValue = true
    this.submitTarget.disabled = true
    this.errorTarget.textContent = ""

    const formData = new FormData(this.element)

    try {
      const response = await fetch(this.element.action, {
        method: "POST",
        body: formData,
        headers: {
          "Accept": "text/vnd.turbo-stream.html"
        }
      })

      if (!response.ok) {
        throw new Error("Error al enviar el formulario")
      }

      const html = await response.text()
      Turbo.renderStreamMessage(html)
    } catch (error) {
      this.errorTarget.textContent = error.message
    } finally {
      this.submittingValue = false
      this.submitTarget.disabled = false
    }
  }

  submittingValueChanged() {
    this.submitTarget.textContent = this.submittingValue
      ? "Enviando..."
      : "Enviar"
  }
}
erb
<%= form_with url: messages_path, data: { controller: "form" } do |f| %>
  <div data-form-target="error" class="text-red-600"></div>

  <%= f.text_area :content, required: true %>

  <%= f.submit "Enviar",
    data: {
      form_target: "submit",
      action: "click->form#submit"
    }
  %>
<% end %>

Comunicación Entre Controladores

Las aplicaciones complejas frecuentemente requieren que múltiples controladores se comuniquen entre sí. Stimulus ofrece varios enfoques para manejar estas interacciones.

El primer enfoque utiliza eventos personalizados de JavaScript:

app/javascript/controllers/cart_controller.jsjavascript
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["count", "total"]
  static values = { items: Array }

  connect() {
    this.element.addEventListener("cart:item-added", this.handleItemAdded.bind(this))
  }

  disconnect() {
    this.element.removeEventListener("cart:item-added", this.handleItemAdded.bind(this))
  }

  handleItemAdded(event) {
    const { item } = event.detail
    this.itemsValue = [...this.itemsValue, item]
    this.updateDisplay()
  }

  updateDisplay() {
    this.countTarget.textContent = this.itemsValue.length
    const total = this.itemsValue.reduce((sum, item) => sum + item.price, 0)
    this.totalTarget.textContent = `$${total.toFixed(2)}`
  }
}
app/javascript/controllers/product_controller.jsjavascript
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static values = { id: Number, name: String, price: Number }

  addToCart() {
    const event = new CustomEvent("cart:item-added", {
      bubbles: true,
      detail: {
        item: {
          id: this.idValue,
          name: this.nameValue,
          price: this.priceValue
        }
      }
    })
    this.element.dispatchEvent(event)
  }
}

Integración con Turbo

Stimulus funciona en conjunto con Turbo, el otro componente principal de Hotwire. Esta sinergia permite crear aplicaciones altamente reactivas mientras se minimiza el JavaScript necesario.

app/javascript/controllers/infinite_scroll_controller.jsjavascript
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["entries", "pagination"]
  static values = { loading: Boolean }

  connect() {
    this.observer = new IntersectionObserver(
      entries => this.handleIntersection(entries),
      { rootMargin: "100px" }
    )

    if (this.hasPaginationTarget) {
      this.observer.observe(this.paginationTarget)
    }
  }

  disconnect() {
    this.observer.disconnect()
  }

  async handleIntersection(entries) {
    const entry = entries[0]
    if (!entry.isIntersecting || this.loadingValue) return

    const nextPageLink = this.paginationTarget.querySelector("a[rel='next']")
    if (!nextPageLink) return

    this.loadingValue = true

    try {
      const response = await fetch(nextPageLink.href, {
        headers: { "Accept": "text/vnd.turbo-stream.html" }
      })
      const html = await response.text()
      Turbo.renderStreamMessage(html)
    } finally {
      this.loadingValue = false
    }
  }
}

El controlador Rails correspondiente retorna Turbo Stream:

ruby
# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  def index
    @articles = Article.order(created_at: :desc).page(params[:page])

    respond_to do |format|
      format.html
      format.turbo_stream
    end
  end
end
erb
<%# app/views/articles/index.turbo_stream.erb %>
<%= turbo_stream.append "articles" do %>
  <%= render @articles %>
<% end %>

<%= turbo_stream.replace "pagination" do %>
  <div id="pagination">
    <%= link_to_next_page @articles, "Más artículos", data: { turbo: false } %>
  </div>
<% end %>

Creación de Controladores Reutilizables

La modularidad es esencial para mantener un código base limpio. Los controladores Stimulus pueden diseñarse para ser altamente configurables mediante valores.

app/javascript/controllers/clipboard_controller.jsjavascript
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["source", "button"]
  static values = {
    successMessage: { type: String, default: "Copiado!" },
    errorMessage: { type: String, default: "Error" },
    resetDelay: { type: Number, default: 2000 }
  }

  async copy() {
    const text = this.sourceTarget.value || this.sourceTarget.textContent

    try {
      await navigator.clipboard.writeText(text)
      this.showFeedback(this.successMessageValue, "success")
    } catch {
      this.showFeedback(this.errorMessageValue, "error")
    }
  }

  showFeedback(message, type) {
    const originalText = this.buttonTarget.textContent
    this.buttonTarget.textContent = message
    this.buttonTarget.classList.add(`feedback-${type}`)

    setTimeout(() => {
      this.buttonTarget.textContent = originalText
      this.buttonTarget.classList.remove(`feedback-${type}`)
    }, this.resetDelayValue)
  }
}
erb
<div data-controller="clipboard"
     data-clipboard-success-message-value="Copiado al portapapeles!">
  <code data-clipboard-target="source">npm install @hotwired/stimulus</code>
  <button data-clipboard-target="button"
          data-action="clipboard#copy">
    Copiar
  </button>
</div>

Pruebas de Controladores Stimulus

Las pruebas garantizan la confiabilidad de los controladores. Existen varios enfoques posibles, incluyendo Jest o las pruebas de sistema de Rails.

test/javascript/controllers/clipboard_controller.test.jsjavascript
import { Application } from "@hotwired/stimulus"
import ClipboardController from "controllers/clipboard_controller"

describe("ClipboardController", () => {
  let application

  beforeEach(() => {
    document.body.innerHTML = `
      <div data-controller="clipboard">
        <input data-clipboard-target="source" value="test content" />
        <button data-clipboard-target="button" data-action="clipboard#copy">
          Copy
        </button>
      </div>
    `

    application = Application.start()
    application.register("clipboard", ClipboardController)
  })

  afterEach(() => {
    application.stop()
  })

  test("copies text to clipboard on click", async () => {
    const writeText = jest.fn().mockResolvedValue(undefined)
    Object.assign(navigator, {
      clipboard: { writeText }
    })

    const button = document.querySelector("[data-action]")
    button.click()

    await new Promise(resolve => setTimeout(resolve, 0))

    expect(writeText).toHaveBeenCalledWith("test content")
  })
})

Optimización del Rendimiento

Aunque los Importmaps eliminan la necesidad de bundling, algunas optimizaciones siguen siendo relevantes para aplicaciones en producción.

La configuración del caché HTTP es fundamental:

ruby
# config/environments/production.rb
config.public_file_server.headers = {
  "Cache-Control" => "public, max-age=31536000, immutable"
}

La precarga de módulos críticos mejora el tiempo de carga inicial:

erb
<%# app/views/layouts/application.html.erb %>
<head>
  <%= javascript_importmap_tags %>
  <%= preload_link_tag "application", as: "script", type: "module" %>
</head>

Para módulos externos voluminosos, se recomienda la carga diferida:

ruby
# config/importmap.rb
pin "chart.js", preload: false
app/javascript/controllers/chart_controller.jsjavascript
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  async connect() {
    const { Chart } = await import("chart.js")
    this.initializeChart(Chart)
  }

  initializeChart(Chart) {
    // Configuración del gráfico
  }
}

¿Listo para aprobar tus entrevistas de Ruby on Rails?

Practica con nuestros simuladores interactivos, flashcards y tests técnicos.

Conclusión

Rails Stimulus e Importmaps representan una evolución significativa en la forma de construir interfaces web interactivas. Al eliminar la complejidad de las herramientas de build tradicionales, este enfoque permite a los desarrolladores enfocarse en la creación de funcionalidades en lugar de la configuración del entorno de desarrollo.

La combinación de Stimulus para el comportamiento JavaScript y Turbo para las actualizaciones dinámicas ofrece una alternativa potente a los frameworks JavaScript pesados. Esta arquitectura respeta los principios fundamentales de Rails: convención sobre configuración y mejora progresiva del HTML.

Para equipos que buscan una solución mantenible y de alto rendimiento sin la complejidad de los bundlers modernos, Stimulus e Importmaps constituyen una opción relevante en 2026. La curva de aprendizaje reducida y la integración nativa con Rails los convierten en una opción particularmente atractiva para proyectos de cualquier tamaño.

Reto diario

¿Sabrías detectar el bug en Ruby on Rails?

Un fragmento real, un bug oculto, un intento al día. Sin cuenta para probar.

Anthony Fillion-Maillet

Escrito por

Anthony Fillion-Maillet

Fundador de SharpSkill

Desarrollador fullstack desde hace más de 10 años. Dirige SharpSkill y responde por todo lo que se publica aquí.

Actualizado el 29 de agosto de 2026

Compartir

Artículos relacionados