Toggling links or buttons based on a user’s role is usually straightforward—something like this works great:
<%= link_to "Admin Panel", "#" if Current.user.admin? %>
But when caching enters the picture, things get messy. Cached partials can unintentionally expose sensitive features to users who shouldn’t see them or clutter the interface with irrelevant options. The same cached content is served to everyone, regardless of their role.
Fortunately, there’s a simple, cache-safe solution to keep your UI clean, secure, and user-specific: always include the link (hidden by default) and use JavaScript to show it when appropriate. Here’s how I set this up.
Note: Always enforce authorization on the server side. Hiding a link on the client might make things look tidy, but it doesn’t make them secure.
First, add the current user-specific values from Rails. These values are scoped to avoid conflicts and included in a <template>
tag so they never appear visibly on the page:
# app/helpers/user_toggle_helper.rb
module UserToggleHelper
def user_toggle_source(scope, values)
tag.template data: {
user_toggle_target: "source",
user_toggle_scope: scope,
user_toggle_values: values
}
end
end
Use this helper to insert scoped values into your templates. Be mindful that these values are globally available, so avoid nils:
<%= user_toggle_source(:user_id, Current.user&.id) %>
<%= user_toggle_source(:group_ids, Current.user&.group_ids) %>
Next, use a Stimulus controller to gather the scoped values dynamically and store them for toggling:
// app/javscript/controlllers/user_toggle_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["source"]
// storage for the current state of toggles
toggleValues = {}
sourceTargetConnected(target) {
const value = JSON.parse(target.dataset.userToggleValue)
const wrappedValue = [].concat(value) // Ensure all values are arrays
const scope = target.dataset.userToggleScope
this.toggleValues[scope] = wrappedValue
this.updateToggles()
}
updateToggles() {
// ..
}
}
Now, create another helper to wrap the elements that should toggle visibility. Initially, these elements will be hidden:
# app/helpers/user_toggle_helper.rb
module UserToggleHelper
# def user_toggle_source …
def user_toggle_toggle(scope, show_if)
tag.div yield, class: "hidden", data: {
user_toggle_target: :toggle,
user_toggle_scope: scope,
user_toggle_show_if: show_if
}
end
end
Use this helper in your views to hide elements like admin links until they’re toggled on by the JavaScript:
<%= user_toggle_toggle(:admin, true) do %>
<%= link_to "Admin", "#" %>
<% end %>
Finally, complete the updateToggles
function to dynamically toggle elements based on the scoped values:
// app/javscript/controlllers/user_toggle_controller.js
export default class extends Controller {
// …
updateToggles() {
Object.keys(this.toggleValues).forEach((scope) => {
this.toggleTargets
.filter((toggle) => toggle.dataset.userToggleScope === scope)
.forEach((toggle) => {
const value = JSON.parse(toggle.dataset.userToggleShowIf)
const show = this.toggleValues[scope].indexOf(value) !== -1
toggle.classList.toggle("hidden", !show)
})
})
}
}
And there you have it—cache-safe elements that dynamically toggle visibility based on user-specific values. Your admin links (and other role-based elements) stay secure and only appear when they should.
Have questions or your own tricks for handling role-based toggles? Hit reply and let me know—I’d love to hear your thoughts!
Next time, I’ll share how to make this solution even faster and more resilient with automatic updates for Turbo Streams. Stay tuned!