Toggling view layouts with Kredis, Turbo Frames, and Rails

Kredis is a new gem that makes it easier to work with Redis keys in Ruby on Rails. Kredis was added a suggested gem for new Rails applications starting with the release of Rails 7.0 in December of 2021 and is likely to become a larger force in the Rails world in the coming years.

From the documentation, Kredis “encapsulates higher-level types and data structures around a single key, so you can interact with them as coherent objects rather than isolated procedural commands”.

What this means for us is that we can use Kredis to make it easier to use Redis as a data store in our application. With Kredis, it is simple to use Redis to read and write complex data structures. Kredis’ integration with ActiveRecord allows us to work with Redis data alongside existing models.

Today we will use Kredis to power a card/list view toggle for a resource’s index page, persisting the user’s view preference across requests. This tutorial will offer a gentle introduction into how Kredis works while exploring how Kredis can help us implement a common real-world UX pattern.

We will also add a bit of Turbo functionality in the form of a Turbo Frame to wrap the list of items to make applying the user’s view preference a bit more efficient.

When we are finished, our application will work like this:

A screen recording of a user of a web application toggling a list of players between a card and a list-based layout and navigating into and back out of a player detail page.

Before we begin, this tutorial is best suited for folks with experience building simple applications with Ruby on Rails. If you have never used Rails before, this tutorial will be difficult to follow. You do not need any prior experience with Turbo or Kredis to follow along with this tutorial.

As usual, you can find the complete code for this application on Github.

Let’s start building.

Application setup

To begin, create a new Rails 7 application from your terminal and scaffold up a Players resource, which we will use as the base for our Kredis-powered view toggle.

rails new kredis-players --css=tailwind
cd kredis-players
./bin/rails g scaffold Player name:string team:string
./bin/rails db:migrate

Note the inclusion of the css=tailwind option in the rails new command. We will use Tailwind to style our application so we can stay focused on building with Kredis instead of copy/pasting CSS.

Once the application is created and the Players resource is scaffolded, you can start up the server and build Tailwind’s CSS with bin/dev.

Because we are building a view toggle for the Players index page, you may also want to seed the database with a few players to save you some manual typing. Head to db/seeds.rb and update it:

10.times do |n|
  Player.create(name: "Player #{n}",  team: "Dallas Mavericks")
end

Then seed the database from your terminal:

./bin/rails db:seed

Build the list and card views

Before introducing Kredis to the application, we will begin by building a list/card view toggle that allows users to swap between the two views by reading URL parameters directly in the view.

Since we used the Rails scaffold generator, we already have a simple list view for Players ready on the index page. Let’s start by updating the generated views to be a little easier to process.

Update app/views/players/_player.html.erb like this:

<li class="border border-gray-200 rounded">
  <div href="#" class="block hover:bg-gray-50">
    <div class="flex items-center px-4 py-4 sm:px-6">
      <div class="min-w-0 flex-1 flex items-center">
        <div class="min-w-0 flex-1 px-4 md:grid md:grid-cols-2 md:gap-4">
          <div>
            <p class="text-sm font-medium"><%= player.name %></p>
          </div>
          <div class="hidden md:block">
            <div>
              <p class="text-sm text-gray-900">
                <%= player.team %>
              </p>
            </div>
          </div>
        </div>
      </div>
      <div>
        <p>
          <%= link_to "View", player, class: "text-blue-700 hover:text-blue-500" %>
        </p>
      </div>
    </div>
  </div>
</li>

Just regular ERB and some Tailwind classes for styling. Now update app/views/players/index.html.erb:

<div class="w-full">
  <% if notice.present? %>
    <p class="py-2 px-3 bg-green-50 mb-5 text-green-500 font-medium rounded-lg inline-block" id="notice"><%= notice %></p>
  <% end %>

  <div class="flex justify-between items-center">
    <h1 class="font-bold text-4xl">Players</h1>
    <%= link_to 'New player', new_player_path, class: "rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium" %>
  </div>

  <div id="players" class="min-w-full">
    <div>
      <ul role="list" class="space-y-4">
        <%= render @players %>
      </ul>
    </div>
  </div>
</div>

Again, just ERB and Tailwind, nothing fancy here.

Next we will add the card view, starting with a _card partial. Create the new partial from your terminal:

touch app/views/players/_card.html.erb

And then fill in the new _card partial:

<li class="col-span-1 flex shadow-sm rounded-md">
  <div class="flex-1 flex items-center justify-between border-t border-r border-b border-gray-200 bg-white rounded-r-md">
    <div class="flex-1 px-4 py-2 text-sm">
      <p class="text-gray-900 font-medium hover:text-gray-600"><%= player.name %></p>
      <p class="text-gray-500"><%= player.team %></p>
    </div>
    <div class="px-4 py-2">
      <p><%= link_to "View", player, class: "text-blue-700 hover:text-blue-500" %></p>
    </div>
  </div>
</li>

With the card partial created, we can now add a simple toggle to the Players index page to switch between the list view and the card view.

Head to app/views/players/index.html.erb and update it:

<div class="w-full">
  <% if notice.present? %>
    <p class="py-2 px-3 bg-green-50 mb-5 text-green-500 font-medium rounded-lg inline-block" id="notice"><%= notice %></p>
  <% end %>

  <div class="flex justify-between items-center">
    <h1 class="font-bold text-4xl">Players</h1>
    <%= link_to 'New player', new_player_path, class: "rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium" %>
  </div>

  <div id="players" class="min-w-full mt-8">
    <% if params[:view] == "card" %>
      <div class="my-4">
        <%= link_to "List view", players_path(view: "list"), class: "bg-blue-800 px-4 py-2 text-white hover:bg-blue-600 rounded" %>
      </div>
      <ul role="list" class="grid grid-cols-1 gap-5 sm:gap-6 sm:grid-cols-2 lg:grid-cols-4">
        <%= render @players %>
      </ul>
    <% else %>
      <div class="my-4">
        <%= link_to "Card view", players_path(view: "card"), class: "bg-blue-800 px-4 py-2 text-white hover:bg-blue-600 rounded" %>
      </div>
        <ul role="list" class="space-y-4">
          <%= render collection: @players, partial: "players/card", as: :player %>
        </ul>
    <% end %>
  </div>
</div>

Here we add a messy but functional if statement that checks the value of params[:view]. When the view param equals card, we render the players as a grid of cards, otherwise the players are rendered as a list. Both of the view also have a link to toggle the index page to the opposite view, with link_to players_path(view: "list"/"card").

This “works” as a starting point. Head to http://localhost:3000/players and click the button to toggle between the views and the page layout changes. However, we can quickly see the limits of this params based approach:

  1. Toggle the index page to from a list to cards
  2. Click on the view link for any player
  3. Click the “Back to players” link on the show page

A screen recording of a user of a web application toggling from a list to a card view on the page. Then they navigate into and back out of a player detail page. When they leave the player detail page, the view has reset from a card view to a list view, losing the user's preference.

Instead of the card layout, the Players index page switches back to the list view. What is happening here? The Players index view relies on the presence of a view URL parameter to determine which layout to display. Because params do not persist between requests, the layout of the Players index page will always fall back to the default list view when visiting /players without any URL parameters.

This is where Kredis comes in. Instead of using the url params to set the view of the Players index page, we can use Kredis to persist the user’s view preference between requests so that the index page retains the expected layout.

Let’s see how Kredis works.

Using Kredis to store preferences

Kredis is a suggested gem in Rails 7, which means it is included in the default Gemfile but it is commented out. To install Kredis, first uncomment it in the Gemfile:

- # gem "kredis"
+ gem "kredis"

Then from your terminal:

bundle install
./bin/rails kredis:install

Restart your Rails application after installing the Kredis gem and running the install task to avoid errors about Kredis being undefined.

At this point, you will also need a Redis server running in your development environment. If you do not already have Redis installed and running, Mac users will find this gist helpful. Linux users may find this guide helpful.

With Kredis installed and Redis running in our local environment, we can now use Kredis to store the user’s view preference instead of relying on the presence of URL parameters.

Head to app/controllers/players_controller.rb and update the index action:

def index
  @players = Player.all
  @user_preferences = Kredis.hash('preferences')
  @user_preferences.update(view: params[:view]) if params[:view].present?
end

Here we are using Kredis to fetch a preferences key from Redis, initializing it as a hash and setting the user_preferences instance variable to be a new instance of a Kredis Hash.

Then, when params[:view] is present in the request, we update the preferences hash to store the value of view. update here updates the preferences key in Redis along with updating the user_preferences instance variable.

Because @user_preferences is an instance of a Kredis Hash, we can treat it (mostly) like a regular hash, as demonstrated by a little bit of experimenting in the console:

irb(main):012:0> preferences = Kredis.hash('preferences')
=>
#<Kredis::Types::Hash:0x00007faed43f0f90
...
irb(main):013:0> preferences.keys
  Kredis Proxy (0.3ms)  HKEYS preferences
=> []
irb(main):014:0> preferences.update(view: 'card')
  Kredis Proxy (0.2ms)  HSET preferences [{:view=>"card"}]
=> 1
irb(main):015:0> preferences.keys
  Kredis Proxy (0.3ms)  HKEYS preferences
=> ["view"]
irb(main):016:0> preferences['view']
  Kredis Proxy (0.3ms)  HGET preferences ["view"]
=> "card"
irb(main):017:0> preferences.delete('view')
  Kredis Proxy (0.3ms)  HDEL preferences ["view"]
=> 1
irb(main):018:0> preferences.keys
  Kredis Proxy (0.2ms)  HKEYS preferences
=> []

To use our new persistent Kredis value instead of URL parameters when rendering the index page, head back to app/views/players/index.html.erb and replace the existing if block with the below:

<% if @user_preferences[:view] == "card" %>
  <div class="my-4">
    <%= link_to "List view", players_path(view: "list"), class: "bg-blue-800 px-4 py-2 text-white hover:bg-blue-600 rounded" %>
  </div>
  <ul role="list" class="grid grid-cols-1 gap-5 sm:gap-6 sm:grid-cols-2 lg:grid-cols-4">
    <%= render @players, partial: "players/card", as: :player %>
  </ul>
<% else %>
  <div class="my-4">
    <%= link_to "Card view", players_path(view: "card"), class: "bg-blue-800 px-4 py-2 text-white hover:bg-blue-600 rounded" %>
  </div>
  <ul role="list" class="space-y-4">
    <%= render @players %>
  </ul>
<% end %>

Here we updated the if condition to check @user_preferences instead of params to determine which layout to render.

With that change in place, head back to http://localhost:3000/players and toggle between the list and card views. If all has gone well, toggling should still work. Even better, if you click on a player and then click to return back to the players index page, your list view preference will be retained.

Neat!

Adding a Turbo Frame

Now that we do not need the URL parameter on every request, it also makes sense to think about what actually needs to be updated when the user toggles between the list and card views. The rest of the page will not change — only the list of players will.

Turbo Frames give us the tools to adjust the application to only update the players section of the page when the user toggles the view, making the application feel faster and more responsive to user input.

For our application, Turbo Frames also resolve a subtle problem that you might have encountered while testing Kredis.

Turbo Drive takes a snapshot of every page to speed up applications. In almost all circumstances, this caching is something we just get for free without needing to think about; however, in this case, caching our players index page creates some issues that we need to resolve.

The issue works like this:

  1. Head to http://localhost:3000/players
  2. Toggle the view from list to card
  3. Click on the view link for a player
  4. Click on the link to go back to the players index page
  5. Notice that for a brief moment, the index page renders the list view before replacing it with the card view

A screen recording of a user of a web application toggling a list of players between a card and a list-based layout and navigating into and back out of a player detail page. When they navigate back to the list page, the conent flashes from a list layout to a card-based layout.

This flash of content happens because Turbo Drive caches the index page before navigating from /players to /players?view=card. The next time you visit /players, Drive uses the original, list-view version of /players from the cache to “preview” the index page before updating it with the new, card-view version of the index page rendered from the server.

Turbo Frames resolve this issue because Turbo does not cache a page when navigation is scoped within a Turbo Frame. Turbo Drive’s snapshot caching only occurs when a full-page navigation occurs.

This means that with Turbo Frames users are free to toggle between list and card views on the players index page as often as they like — the content of the index page will not be cached until they navigate away from the index page entirely.

Because the content is not cached until the final navigation, the correct layout of the page gets cached and the “preview” displayed by Turbo on the next visit to the index page matches the final version, eliminating the flash of bad cached content.

Let’s see how this works in practice.

First, update app/views/players/index.html.erb to wrap the list of players in a Turbo Frame:

<div class="w-full">
  <% if notice.present? %>
    <p class="py-2 px-3 bg-green-50 mb-5 text-green-500 font-medium rounded-lg inline-block" id="notice"><%= notice %></p>
  <% end %>

  <div class="flex justify-between items-center">
    <h1 class="font-bold text-4xl">Players</h1>
    <%= link_to 'New player', new_player_path, class: "rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium" %>
  </div>

  <%= turbo_frame_tag "players", class: "min-w-full mt-8" do %>
    <% if @user_preferences[:view] == "card" %>
      <div class="my-4">
        <%= link_to "List view", players_path(view: "list"), class: "bg-blue-800 px-4 py-2 text-white hover:bg-blue-600 rounded" %>
      </div>
      <ul role="list" class="grid grid-cols-1 gap-5 sm:gap-6 sm:grid-cols-2 lg:grid-cols-4">
        <%= render @players, partial: "players/card", as: :player %>
      </ul>
    <% else %>
      <div class="my-4">
        <%= link_to "Card view", players_path(view: "card"), class: "bg-blue-800 px-4 py-2 text-white hover:bg-blue-600 rounded" %>
      </div>
        <ul role="list" class="space-y-4">
          <%= render @players %>
        </ul>
    <% end %>
  <% end %>
</div>

Here we removed the “players” div and replaced that div with a <turbo-frame id="players", using the turbo-rails provided turbo_frame_tag helper method.

Now that the list of players is wrapped in a Turbo Frame, links inside of that frame will update the content of that frame instead of updating the entire page.

This means that when the user clicks on the view toggle links, Turbo will extract the content of the players Turbo Frame from the response and use that content to update the players frame while discarding the rest of the content. When a Turbo Frame request is initiated, Rails helpfully renders the response without a layout, saving a (very small amount of) work on the server.

Finish up adding Turbo Frame support by updating the links to view players in both the card and player partials like this:

<%= link_to "View", player, class: "text-blue-700 hover:text-blue-500", data: { turbo_frame: "_top" } %>

The addition of data-turbo-frame="_top" to links wrapped inside of a <turbo-frame> tells Turbo to perform a normal full-page navigation, instead of scoping the navigation within the frame. This ensures that going to players#show still works as expected.

After making these changes, head back to http://localhost:3000/players, toggle the view from list to card (or card to list) and then click to view a player, and finally click back out to the players index page. If all has gone well you will not see any flashing cached content.

A screen recording of a user of a web application toggling a list of players between a card and a list-based layout and navigating into and back out of a player detail page.

Improving our Kredis usage

Our current implementation of Kredis will only work if our application has exactly one user. This is because we are using a static key, preferences, to set the players list layout.

In the real world, we will (hopefully!) have more than one user in our application. Each of our users should be able to store their own view preferences or our view toggle will not be a very useful feature! Let’s wrap up this tutorial by looking at how we can use Kredis in ActiveRecord models to store user-specific preferences, making view toggling much more useful.

From your terminal, create a User model:

./bin/rails g model User session:string
./bin/rails db:migrate

And then update app/controllers/application_controller.rb:

class ApplicationController < ActionController::Base
  helper_method :current_user

  def current_user    
    @current_user ||= User.find_or_create_by(session: session.id.to_s)
  end
end

We do not want to build out a whole user authentication system to learn more about Kredis, so here we are faking it by using session.id to find or create a new user in the database and then defining current_user as a helper_method available throughout our application.

In a real application, current_user would be a real, actual, logged in user, but our fake users will be good enough to demonstrate the key concept here.

Our goal is to be able to associate user preferences with a User using Kredis. Our first implementation of Kredis hardcoded a preferences key to store all preferences. Our new implementation will give each User their own unique key through Kredis’ integration with ActiveRecord.

Head to app/models/user.rb and update it like this:

class User < ApplicationRecord
  kredis_hash :preferences
end

This update means that all users will have a unique key/value pair that stores their preferences in Redis. We can access this attribute with user.preferences, which will return an instance of a Kredis::Hash, just like Kredis.hash('preferences') did in our controller.

To use this new preferences attribute, update the PlayersController#index action in app/controllers/players_controller.rb:

def index
  @players = Player.all
  current_user.preferences.update(view: params[:view]) if params[:view].present?
end

Here we use the same update method we used before, this time using current_user.preferences to access a unique hash for the current user.

Then update the players index view to reference the right hash:

<div class="w-full">
  <% if notice.present? %>
    <p class="py-2 px-3 bg-green-50 mb-5 text-green-500 font-medium rounded-lg inline-block" id="notice"><%= notice %></p>
  <% end %>

  <div class="flex justify-between items-center">
    <h1 class="font-bold text-4xl">Players</h1>
    <%= link_to 'New player', new_player_path, class: "rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium" %>
  </div>

  <%= turbo_frame_tag "players", class: "min-w-full mt-8" do %>
    <% if current_user.preferences[:view] == "card" %>
      <div class="my-4">
        <%= link_to "List view", players_path(view: "list"), class: "bg-blue-800 px-4 py-2 text-white hover:bg-blue-600 rounded" %>
      </div>
      <ul role="list" class="grid grid-cols-1 gap-5 sm:gap-6 sm:grid-cols-2 lg:grid-cols-4">
        <%= render @players, partial: "players/card", as: :player %>
      </ul>
    <% else %>
      <div class="my-4">
        <%= link_to "Card view", players_path(view: "card"), class: "bg-blue-800 px-4 py-2 text-white hover:bg-blue-600 rounded" %>
      </div>
        <ul role="list" class="space-y-4">
          <%= render @players %>
        </ul>
    <% end %>
  <% end %>
</div>

The if statement now checks current_user.preferences instead of @user_preferences.

To test out these changes, open two separate browsers, toggle the list/card view to different options in each window, and then refresh each window to see that each “user” has their own unique preferences.

With that last change, you have reached the end of this tutorial! Incredible work today.

Wrapping up

Today we learned a little about how to use Kredis to unlock more power from our Redis-enabled Ruby on Rails applications.

Kredis’ tight integration with ActiveRecord gives it a leg up on other tools that are often used to do this type of work — if you have used Rails for a while, you have probably stuffed view preferences, filter and search terms, and other information into the session to persist the information across page turns. Redis is a more resilient and more appropriate tool for storing this type of information, and Kredis makes it simple to use Redis for this type of work in Ruby on Rails applications.

While we used a hash for storing data, Kredis supports a variety of datatypes to suit your needs — explore the repo to see the full list of available types.

One of the more exciting aspects of Kredis is the tools that can be built on top of it — we can build our own simple functionality like the preference storage we created today, but other smart folks are building gems on top of Kredis to enable functionality like presence tracking for application resources or complex, multi-stage forms.

That’s all for today. As always, thanks for reading!

Subscribe to Hotwiring Rails

Enter your email to subscribe to a once-monthly newsletter curating the latest content on Rails, Hotwire, and other things you might find interesting.