Crea autocompletado nativo en formularios Rails con f.datalist (sin JavaScript)
Note: f.datalist llega en Rails 8.2. Está mergeado en main pero aún no se ha publicado. Puedes ver el pull request, apuntar tu Gemfile a la rama main para probarlo, o clonar la app de demostración y ejecutar bin/rails server. Si prefieres no esperar, el datalist_tag subyacente está disponible desde Rails 8.0; más sobre eso abajo. También vale la pena revisar: atributos JSON con tipos seguros gracias a has_json, otra incorporación de 8.2.
Hay una distancia molesta entre “quiero sugerencias en este campo de texto” y “acabo de añadir una dependencia de JavaScript, una acción de controlador y un controlador de Stimulus”. El navegador tiene una respuesta nativa desde hace años, el elemento <datalist>, y Rails 8.2 añade un helper de FormBuilder para usarlo.
Para que quede claro, esto no es una capacidad nueva. Ya podías renderizar un datalist con datalist_tag (Rails 8.0), o con HTML plano antes de eso. Lo que aporta f.datalist es el cableado del id, de modo que el input y su lista no se desincronicen. Es algo pequeño, pero es un bug que de hecho he llevado a producción, y el arreglo son dos líneas en lugar de tres.
Vamos a construir un formulario de registro con un campo de país que sugiere coincidencias mientras escribes. Sin librería de JS, sin fetch, sin estado en el cliente.
Lo que vamos a construir
Un formulario de Profile con un input country respaldado por una lista de sugerencias nativa. El navegador se encarga del typeahead y nunca salimos del bloque form_with.
La funcionalidad de Rails: f.datalist
f.datalist envuelve el datalist_tag existente y deriva el id del elemento para que el input y la lista queden conectados:
# actionview/lib/action_view/helpers/form_options_helper.rb
def datalist(method, choices = nil, html_options = {})
@template.datalist_tag(field_id(method, "datalist"), choices, html_options)
end
La pieza clave es field_id(method, "datalist"). Un <datalist> solo funciona cuando el atributo list del input coincide con el id del datalist. Este es el mismo formulario sin el helper:
<%# El id es una cadena literal que ahora escribes idéntica en dos sitios %>
<%= f.text_field :country, list: "profile_country_datalist" %>
<%= datalist_tag "profile_country_datalist", Profile::COUNTRIES %>
Funciona, pero el id es una cadena hardcodeada en dos sitios. Renombra el campo o el modelo y se desincronizan, y el autocompletado deja de funcionar en silencio, sin ningún error. field_id construye el mismo id a partir del nombre del campo en ambos lados, así que siempre coinciden.
datalist_tag está en las versiones publicadas de Rails desde la 8.0, así que la versión manual funciona hoy sin Rails de edge. f.datalist solo elimina el trabajo de gestionar el id a mano.
Construyéndolo
Empieza con un modelo simple:
class Profile < ApplicationRecord
validates :country, presence: true
end
Luego el formulario. Las dos líneas que importan son el text_field y el datalist:
<%= form_with model: @profile do |f| %>
<div>
<%= f.label :country %>
<%= f.text_field :country, list: f.field_id(:country, :datalist) %>
<%= f.datalist :country, ["Argentina", "Brazil", "Canada", "Chile", "Japan"] %>
</div>
<%= f.submit %>
<% end %>
El list: del input y el id que produce f.datalist son ambos field_id(:country, :datalist), así que el navegador los empareja.
Esto se renderiza como:
<input list="profile_country_datalist" type="text"
name="profile[country]" id="profile_country" />
<datalist id="profile_country_datalist">
<option value="Argentina">Argentina</option>
<option value="Brazil">Brazil</option>
<option value="Canada">Canada</option>
<option value="Chile">Chile</option>
<option value="Japan">Japan</option>
</datalist>
Viéndolo funcionar
Escribe en el campo de país y el navegador muestra un desplegable filtrado. “C” reduce a Canada y Chile, “Ca” reduce a Canada. Elige uno para rellenar el input, o ignora la lista y escribe lo que quieras, ya que un datalist sugiere valores en lugar de restringirlos.
Nada llegó al servidor y no se ejecutó JavaScript. El navegador hizo el filtrado por su cuenta.
No hay captura de pantalla porque el desplegable lo dibuja el sistema operativo, no la página, así que no aparece en las capturas. Arranca la app de demostración para verlo de verdad.
Poblando las opciones desde la base de datos
Los arrays hardcodeados están bien para una demo. El uso real es alimentar las sugerencias desde tus datos. El argumento choices acepta el mismo formato que options_for_select, así que el resultado de una consulta encaja directamente:
<%= f.text_field :tag, list: f.field_id(:tag, :datalist) %>
<%= f.datalist :tag, Tag.where.not(name: [nil, ""]).distinct.order(:name).pluck(:name) %>
El distinct y el descarte de valores en blanco mantienen los duplicados y las cadenas vacías fuera de la lista. Lleva esa consulta a un scope o a un método del modelo en código real. Y como el campo es un text_field normal, editar un registro existente no requiere nada extra: se rellena con el valor guardado y sigue ofreciendo la lista.
La forma [label, value] también funciona, igual que en un select:
<%= f.datalist :country_code,
[["Argentina", "AR"], ["Brazil", "BR"], ["Canada", "CA"]] %>
Pero un <datalist> no es un <select>. Eso renderiza <option value="AR">Argentina</option>, y un datalist compara lo que escribes contra el value (“AR”), inserta el value e muestra el value como sugerencia. El texto (“Argentina”) es una pista secundaria en el mejor de los casos, y algunos navegadores lo ignoran. El comportamiento de “mostrar el nombre, enviar el código” que obtienes de un select no se traslada aquí, y escribir “Arg” no encontrará “Argentina”. Pon en el value lo que quieras que los usuarios escriban, lo que normalmente significa que la forma de array simple es la que quieres.
Llevándolo más lejos
Algunas direcciones que vale la pena explorar:
- Varias listas en un mismo formulario. Cada id se deriva de su propio nombre de método, así que
f.datalist :countryyf.datalist :citynunca chocan. Añade tantas como quieras. - HTML por opción. El formato acepta un hash de opciones final, así que
["Chile", "CL", { disabled: true }]renderiza una opción deshabilitada. - Listas compartidas. Para varios inputs que comparten un mismo conjunto, recurre al
datalist_tag('shared_id', choices)plano y apunta ellist:de cada input a"shared_id".
Cosas que vigilar
Un datalist solo ofrece sugerencias; no impedirá que nadie escriba algo fuera de la lista. Lo que quede en el campo es lo que llega a tu controlador. Así que sigue validando en el servidor: validates :country, presence: true, más una comprobación inclusion si solo aceptas un conjunto conocido. El datalist es para la comodidad al escribir; el modelo sigue siendo el dueño de la corrección.
Una advertencia: no pongas autocomplete="off" en el input. Es un hábito común para anular el autorrelleno del propio navegador, pero Safari y otros navegadores WebKit lo leen como “sin sugerencias” y ocultan el datalist. Si la lista aparece en Chrome pero no en Safari, casi siempre es por esto. Deja el autocomplete fuera del input, o ponle un token real, y la lista vuelve.
Otra cosa que vigilar: cada opción viaja en el HTML, y no hay filtrado del lado del servidor. Unas pocas centenas de entradas están bien. “Todas las ciudades del país” o “los 40.000 usuarios” significa un payload inflado y una lista lenta en cada pulsación. Usa f.datalist para conjuntos acotados que no te importe enviar enteros. Más allá de eso, querrás un autocompletado real respaldado por un endpoint.
El desplegable también tiene límites. No puedes darle estilo, el soporte de lectores de pantalla varía según el navegador y el comportamiento en móvil difiere según el dispositivo. Para un conjunto conocido de sugerencias nada de eso importa mucho, pero si necesitas estilos, accesibilidad completa o filtrado remoto, ahí es donde un combobox de JavaScript se gana su sitio.
Para terminar
El autocompletado nativo en un formulario Rails ahora son dos líneas: un text_field con un atributo list: y un f.datalist que deriva el id que coincide. Para el caso común de “sugerir desde un conjunto conocido”, ese es todo el trabajo. Mira el PR #57318 para la implementación.