Building a Tag Input from Scratch with Rails and Stimulus
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 intochips: where the blue pill badges renderdropdown: the autocomplete suggestion listhiddenContainer: 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, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
}
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.
Comments ()