Building a Tag Input from Scratch with Rails and Stimulus

Building a Tag Input from Scratch with Rails and Stimulus
Photo by Anh Phan / Unsplash

A few weeks ago, I added a tagging feature to my personal finance app, Foxance. I wanted users to label their transactions with custom tags, up to 3 per transaction, with autocomplete and chip-style UI.

My first instinct was to grab a library. But then I stopped myself. Do I really need another npm package for this? Let me try with just Stimulus and a bit of Rails.

Spoiler: it worked well. Here's how I built it.

The Data Model

First, the Rails side. Tags belong to an Account, not to individual users. This is important because in Foxance, multiple users can share an account. If tags belonged to a user, other members couldn't see each other's tags. Sharing them at the account level makes them a shared vocabulary.

class Tag < ApplicationRecord
  belongs_to :account
  has_many :transaction_tags, dependent: :destroy
  has_many :transactions, through: :transaction_tags, source: :txn

  validates :name, presence: true, uniqueness: { scope: :account_id, case_sensitive: false }
  before_save { self.name = name.strip.downcase }
end

tag.rb

The join model is simple:

class TransactionTag < ApplicationRecord
  belongs_to :txn, class_name: "Transaction", foreign_key: :transaction_id
  belongs_to :tag
end

transaction_tag.rb

And Transaction gets the association plus some logic we'll talk about shortly:

class Transaction < ApplicationRecord
  has_many :transaction_tags, dependent: :destroy
  has_many :tags, through: :transaction_tags

  attr_writer :tag_names

  def tag_names
    @tag_names || tags.map(&:name)
  end

  validate :tag_count_within_limit
  after_save :sync_tags
end

transaction.rb

The sync_tags Pattern

This is the most interesting part on the Rails side. Instead of dealing with complex params parsing in the controller, I used an attr_writer + after_save pattern.

The form sends tags as transaction[tag_names][], an array of strings. The controller just passes it through with permit(tag_names: []). Then sync_tags handles everything:

def sync_tags
  return if @tag_names.nil?
  names = Array(@tag_names).map { |n| n.to_s.strip.downcase }.reject(&:blank?).uniq.first(3)
  self.tags = names.map { |name| Tag.find_or_create_by!(account: account, name: name) }
  @tag_names = nil
end

Two things worth noting here:

find_or_create_by!: If the tag "coffee" already exists for this account, it reuses it. If not, it creates it. No duplicates, no extra logic.

self.tags = [...]: This is Active Record's collection assignment. It automatically computes the diff and inserts/deletes only what changed. So if a transaction had ["coffee", "work"] and you save with ["coffee", "travel"], Rails removes the work association and adds travel. One line, zero boilerplate.

The Autocomplete Endpoint

A minimal controller to power suggestions:

class TagsController < ApplicationController
  def index
    tags = Current.account.tags
    tags = tags.where("name LIKE ?", "%#{params[:q].to_s.strip.downcase}%") if params[:q].present?
    render json: tags.order(:name).limit(10).pluck(:name)
  end
end

tags_controller.rb

Returns a JSON array of tag names. That's it.

The Stimulus Controller

Now the fun part. The controller has four targets:

  • input: the text field the user types into
  • chips: where the blue pill badges render
  • dropdown: the autocomplete suggestion list
  • hiddenContainer: holds the actual <input type="hidden"> fields that get submitted with the form
static targets = ["input", "chips", "dropdown", "hiddenContainer"]
static values = { max: { type: Number, default: 3 }, url: String }

tag_input_controller.js

Adding Tags

Tags can be added three ways: pressing Enter, pressing comma, or clicking a suggestion. They all call the same addTag() method:

addTag(name) {
  name = name.trim().toLowerCase()
  if (!name || this.tags.includes(name) || this.tags.length >= this.maxValue) return
  this.tags.push(name)
  this.inputTarget.value = ""
  this.closeDropdown()
  this.renderChips()
}

Backspace to Remove

This is one of those small UX details that users expect without realizing it. When the input is empty and the user presses Backspace, the last tag gets removed:

} else if (event.key === "Backspace" && this.inputTarget.value === "" && this.tags.length > 0) {
  this.tags.pop()
  this.renderChips()
}

The blur/preventBlur Problem

This is a classic trap. When a user clicks a suggestion in the dropdown, the browser fires blur on the input before the click on the button. If you close the dropdown on blur, the click never lands.

The fix:

blur() { setTimeout(() => this.closeDropdown(), 150) }
preventBlur(event) { event.preventDefault() }

Each dropdown button has mousedown->tag-input#preventBlur. The mousedown event fires before blur. Calling preventDefault() on it stops the input from losing focus, so the click goes through normally. The setTimeout in blur is a safety net for cases where the user clicks somewhere else entirely.

Rendering Chips and Hidden Inputs

renderChips() does two things at once: it updates the visual chips and regenerates the hidden inputs.

renderChips() {
  this.hiddenContainerTarget.innerHTML = this.tags.map(t =>
    `<input type="hidden" name="transaction[tag_names][]" value="${this.esc(t)}">`
  ).join("")

  this.chipsTarget.innerHTML = this.tags.map(t =>
    `<span class="inline-flex items-center gap-1 bg-blue-100 text-blue-700 ...">
      ${this.esc(t)}
      <button type="button" data-action="click->tag-input#removeTag" data-name="${this.esc(t)}">×</button>
    </span>`
  ).join("")

  const atMax = this.tags.length >= this.maxValue
  this.inputTarget.disabled = atMax
  this.inputTarget.classList.toggle("hidden", atMax)
}

When the user hits 3 tags, the input hides itself. Clean, no extra state needed.

XSS: Don't Skip This

Since we're writing directly to innerHTML, we need to escape user input. I wrote a small esc() helper:

esc(str) {
  return String(str)
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
}

Tag names come from the user. Without escaping, a tag named "><script>alert(1)</script> would execute. A small helper, but not optional.

Wiring It All Together in the Form

<div data-controller="tag-input"
     data-tag-input-url-value="<%= tags_path %>"
     data-tag-input-max-value="3">

  <div class="... flex flex-wrap gap-1.5 items-center">
    <div data-tag-input-target="chips" class="contents"></div>
    <div class="relative">
      <input type="text"
             data-tag-input-target="input"
             data-action="input->tag-input#suggest keydown->tag-input#keydown blur->tag-input#blur">
      <div data-tag-input-target="dropdown" class="hidden ..."></div>
    </div>
  </div>

  <div data-tag-input-target="hiddenContainer">
    <% transaction.tags.each do |tag| %>
      <input type="hidden" name="transaction[tag_names][]" value="<%= tag.name %>">
    <% end %>
  </div>
</div>

The hiddenContainer is pre-populated with existing tags when editing. The Stimulus connect() reads them back into this.tags, so the edit form shows the right chips immediately.


What I Liked About This Approach

No external dependencies.

The whole thing is about 110 lines of JavaScript and a handful of Ruby. It's readable, testable, and easy to extend.

The sync_tags pattern with find_or_create_by! and self.tags = [...] is something I'll reuse. It keeps all the tag logic in the model where it belongs, and the controller stays clean.

If you're reaching for a library for something like this, it's worth spending 30 minutes seeing if Stimulus and a small Rails endpoint can get you there first. Often they can.