Signed by The Studio

FlockUp tabs with Turbo Frames

dev
Kaamil
by Kaamil

This is a quick post on using Turbo Frames on Ruby on Rails to create a tabbed interface on a page. We’re using our own community coordination product, flockUp, for this example.

Tab-less chaos

A flock is a flockUp account’s sub-group. It’s a way to organize your members into different contexts, whether thats event based, geographic, or otherwise.

The flock detail page used to look like this: Outreach Accordions

Host Accordion

Not only does the media team look massively distracted, but these expandable sections take way too much space! They were handy for a quick growth, but they got cumbersome for a few reasons:

  • A user does not know all the sections that exist without scrolling
  • The expand arrows were annoying to click and unclick to keep the page clean
  • The padding of each section took up a lot of space

That said, I love these expandable sections in a pinch - we’ve just outgrown them here. Here is what the code for them looks like, using daisyUI, and no custom javascript (in case you find it as handy as I do).

<details class="collapse" open>
	<summary class="collapse-title"><!-- Title content goes here --></summary>
	<div class="collapse-content"><!-- Details go here --></div>
</details>

These three sections, outreach, members and hosts could also all be expanded at the same time, which ended up requiring a lot of scrolling.

Tabs are ideal here because it shows users all the components of a flock, conserves space and is more intuitive to click on. To improve this whole page, I compressed the flock overview, removed the message count, and then quickly converted the sections to tabs using good old javascript - but there was a major issue that arose with the tabs.

The dreaded page reload

In flockUp, we are using erb or embedded ruby to render the page, so when making a database change, new html to be sent over the wire, resulting in a full page reload in this case. Because the current tab is stored client side, this means the tab gets reset every time a user adds a new member, or host for example. This context switching is jarring and not fun.

To be clear, if our tabs were readonly this wouldn’t be an issue - it is the editing of members and hosts that requires new html.

Client side solutions like react, vue, or svelte handle this by updating the UI on reactive state changes, such as json responses triggering a re-render. With erb, we aren’t using any javascript framework or json. Data flows from the database to ruby models directly to html (via erb).

The solution

Without javascript frameworks synchonizing data and UI, there was only one straight forward solution to ensure the app remained on the same tab when running database changes:

Each tab needs its own url, and the top part of the page must be repeated for each view.

This is the current route with all the tabs, where I made outreach the starting tab:

  • /flock/[id] which renders flocks/show.html.erb

I added the following routes and views for the members and host tabs, adding to the flock_controller.rb:

flocks_controller

  def members
    @flock = Flock.find(params[:flock_id])
    render "flocks/members"
  end

  def hosts
    @flock = Flock.find(params[:flock_id])
    render "flocks/hosts"
  end
  • /flock/[id]/members which renders flocks/members.html.erb
  • /flock/[id]/hosts which renders flocks/hosts.html.erb

Each page, /flock/[id], /flock/[id]/members, and /flock/[id]/hosts, looks like this:

<!-- Flock overview code here -->
 <div id="tab-navigation">
    <%= link_to "Outreach", flock_path(@flock),
        class: "#{current_page?(flock_path(@flock)) ? 'active' : 'inactive'}" %>
    <%= link_to "Members", flock_members_path(@flock),
        class: "#{current_page?(flock_members_path(@flock)) ? 'active' : 'inactive'}" %>
    <%= link_to "Hosts", flock_hosts_path(@flock),
        class: "#{current_page?(flock_hosts_path(@flock)) ? 'active' : 'inactive'}" %>
</div>
<!-- Tab content goes here -->

which renders the following html when on the outreach tab:

<!-- Flock overview code here -->
<div id="tab-navigation">
	<a href="flock/[id]" class="active">Outreach</a>
	<a href="flock/[id]/members" class="inactive">Members</a>
	<a href="flock/[id]/hosts" class="inactive">Hosts</a>
</div>
<!-- Tab content goes here -->

This separated functionality more clearly and reduced the jarring context switching from before. This multi-page approach is how most of the web was built before SPAs (single-page-application) that js frameworks like react popularized.

But there is a reason js frameworks became popular. With this multi-page approach some problems are:

  • Each page requires a sizeable amount of duplicate code to include the same flock overview in each page
  • The scroll position is not maintained when the tab is changed
  • Switching tabs requires a full page load for a relatively small change on-screen

This made the multi-page approach feel inelegant for developers and users compared to what the js frameworks offer. Can we make this better in an environment where js is largely avoided?

Enter Turbo Frames

Turbo frames are a way to render a partial html response, while preserving the rest of the page. It comes with Rails 7+ so no installation was necessary for us. The usage is dead-simple.

All that must be done is to wrap the html to be replaced on the first page with a element, and then wrap the target html in another . Upon clicking a link or a submit button, if the target page contains turbo frames have the same id, the html will swap and preserve the rest of the page!

More on how to use turbo frames can be found on the the hotwired docs and on this hotrails guide if you are using turbo in rails.

Turbo frames allows us to remove all the duplicate navigation code from the hosts and members views:

  • The turbo_frame is given an id of flock-tab
  • The content of the tab on each page is wrapped in a turbo-frame element with the same id
  • Duplicate flock overview code can be removed from the additional tabs

So things look like this now.

flocks/show.html.erb

<!-- Flock overview here -->
<%= turbo_frame_tag "flock-tab" do %>
    <div id="tab-navigation">
      <%= link_to "Outreach", flock_path(@flock),
          class: "#{current_page?(flock_path(@flock)) ? 'active' : 'inactive'}" %>
      <%= link_to "Members", flock_members_path(@flock),
          class: "#{current_page?(flock_members_path(@flock)) ? 'active' : 'inactive'}" %>
      <%= link_to "Hosts", flock_hosts_path(@flock),
          class: "#{current_page?(flock_hosts_path(@flock)) ? 'active' : 'inactive'}" %>
    </div>
    <!-- Outreach content goes here -->
<% end %>

which renders the following html:

<!-- Flock overview here -->
<turbo-frame id="flock-tab">
	<div id="tab-navigation">
		<a href="flock/[id]" class="active">Outreach</a>
		<a href="flock/[id]/members" class="inactive">Members</a>
		<a href="flock/[id]/hosts" class="inactive">Hosts</a>
	</div>
	<!-- Outreach content goes here -->
</turbo-frame>

flocks/members.html.erb and flocks/hosts.html.erb

<!-- Flock overview code is removed -->
<%= turbo_frame_tag "flock-tab" do %>
    <div id="tab-navigation">
      <%= link_to "Outreach", flock_path(@flock),
          class: "#{current_page?(flock_path(@flock)) ? 'active' : 'inactive'}" %>
      <%= link_to "Members", flock_members_path(@flock),
          class: "#{current_page?(flock_members_path(@flock)) ? 'active' : 'inactive'}" %>
      <%= link_to "Hosts", flock_hosts_path(@flock),
          class: "#{current_page?(flock_hosts_path(@flock)) ? 'active' : 'inactive'}" %>
    </div>
    <!-- Outreach content goes here -->
<% end %>

which renders the following html when on the members tab:

<turbo-frame id="flock-tab">
	<div id="tab-navigation">
		<a href="flock/[id]" class="inactive">Outreach</a>
		<a href="flock/[id]/members" class="active">Members</a>
		<a href="flock/[id]/hosts" class="inactive">Hosts</a>
	</div>
	<!-- Tab content goes here -->
</turbo-frame>

Now, using the same navigation code, with the same hrefs as before, turbo frames will swap just the content of the tab element on the page, while preserving the rest of the page and scroll position. Changes inside the tab do not reset the tabs either! Now our page behaves like a single page application in regards to data reloads and state preservation but without the javascript framework. Turbo runs all the js we need!

A few things changed now that we are using turbo frames:

  • The url does not change when the tab is changed, even though the tab links to a different url.
  • The ruby content inside a turbo frame operates as if it is on its original route (like before)
  • If inside a turbo frame, other internal links and submit buttons expect to point to a view with a turbo-frame with the same id to swap html, unless otherwise specified.

Here’s what the tabs look like now, including some UI cleanup on the flock overview:

Outreach Accordions

Host Accordion

Conclusion

Turbo frames are a simple and effective way to add a single page application like feel to your page, but without the javascript. I will say, they take a minute to get used to coming from a js framework, but the workflow in rails that made it click for me is this:

  • Make a route, controller method, and view for the page
  • Test all functionality works on the individual pages, unit tests and UI
  • Add turbo frames to a main page, and then swap out content with working ui in the other views

This way everything stays organized and tested, then all combined into one page to get that single page application feel. In my experience it keeps functionality and UX separate in a way that is great for my brain!