Jonathan Bennett

Cache-Safe User Toggles in Rails–Here’s How

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.


1. Include Values in Your Page with a Helper

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) %>

2. Collect Scoped Values with a Stimulus Controller

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() {
		// ..
	}
}

3. Wrap Elements to Be Toggled

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 %>

4. Toggle Element Visibility with JavaScript

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!