UI Context and Rails Variants
This is a follow-up to Scoping Rails Controllers by UI Context, where I suggested a controller architecture for handling the same action in multiple contexts.
For example, in Apple’s iOS Mail app, you can delete a message in at least 3 different places: when reading the message itself, by swiping in a list, or deleting via a context menu. In each case, the code to delete the message might be the same, but the visual response differs.
The previous post suggested a 1:1 mapping between routes and controller actions to handle each case, scoped by the UI context:
DELETE /messages/:id => messages#destroy
(default case)DELETE /list_items/messages/:id => list_items/messages#destroy
DELETE /context_menus/messages/:id => context_menus/messages#destroy
The deletion code could be shared through inheritance or composition, but the responses would vary.
Variants
After I shared this on Kasper’s Discord, Kasper suggested an approach using variants. Rather than a 1:1 mapping of routes to controller actions, Kasper suggested mapping different routes to a single controller action, and switching the response based on the variant. The variant could be sent as a default param and set in the controller. For example, the routes might look as follows:
# config/routes.rb
Rails.application.routes.draw do
resources :messages
scope :list_items, as: :list_items, defaults: {variant: :list_items} do
resources :messages
end
scope :context_menus, as: :context_menus, defaults: {variant: :context_menus} do
resources :messages
end
# …
end
# (Yes, there is repetition here, which could be cleaned up)
With this in place, we have the following routes that all map to messages#destroy
, but each has a different params[:variant]
.
DELETE /messages/:id => messages#destroy
DELETE /list_items/messages/:id => messages#destroy
DELETE /context_menus/messages/:id => messages#destroy
The request variant could then be set as follows:
class ApplicationController < ActionController::Base
before_action :set_variant
VARIANTS = [:list_items, :context_menus]
def set_variant
request.variant << params[:variant].to_sym if params[:variant].in?(VARIANTS)
end
# …
end
From here we can customise our responses. We could do this via templates or via a respond_to
call:
class MessagesController < ApplicationController
def destroy
@message = Message.find(params[:id])
@message.destroy!
respond_to do |format|
format.turbo_stream.none {…}
format.turbo_stream.list_items {…}
format.turbo_stream.context_menus {…}
end
end
# …
end
Overall, I really like the variants approach. Routing different paths to a single controller action feels cleaner and more manageable. I think the only downside is if we needed additional device-specific variants i.e. if we also wanted to render a different context menu on desktop vs mobile, it might be tricky.