Storing Ephemeral UI State with Kredis for Rails
Tags: ruby, rails
Published: 2023-03-01 09:54:55

[Kredis (Keyed Redis)](https://github.com/rails/kredis) is a recent addition to the Rails developer's toolkit. It strives to simplify storing and accessing structured data on Redis.

In this first part of a two-part series, we'll start by going into how Kredis works. We'll then run through an example use case for storing ephemeral UI state using a bespoke Redis key.

Let's get started!

## An Introduction to Kredis for Rails

Kredis is a Railtie that provides convenient wrappers to streamline its use in three ways:

- **Ruby-esque API**: For example, collection types like `Kredis.list` or `Kredis.set` emulate native Ruby types (and their respective API) as much as possible.
- **Typings**: Especially handy for collections, Kredis can handle [type casting](https://github.com/rails/kredis/blob/main/lib/kredis/type_casting.rb) the elements from/to standard data types (e.g., `datetime`, `json`).
- **ActiveRecord DSL**: Probably the library's biggest asset, it allows you to easily connect any Redis data structure with a _specific model instance_.

Here's an example from the [README](https://github.com/rails/kredis):

```ruby
class Person < ApplicationRecord
  kredis_list :names
  kredis_unique_list :skills, limit: 2
  kredis_enum :morning, values: %w[ bright blue black ], default: "bright"
  kredis_counter :steps, expires_in: 1.hour
end

person = Person.find(5)
person.names.append "David", "Heinemeier", "Hansson" # => RPUSH people:5:names "David" "Heinemeier" "Hansson"
true == person.morning.bright?                       # => GET people:5:morning
person.morning.value = "blue"                        # => SET people:5:morning
true == person.morning.blue?                         # => GET people:5:morning
```

Kredis' major benefit is the ease it provides to store ephemeral information associated with a certain record, but _independent of the session_. Typically, when you need to persist data in Rails, you have a few options — of which the two most common ones are:

- **ActiveRecord**: In most cases, this requires adding a column or otherwise patching your data model. A migration is needed, plus the optional backfilling of old records.
- **Session**: The default key/value store of every Rails app and requires no or little setup. The downside is that data stored in it doesn't survive a login/logout cycle.

Kredis brings a third option to the table. Little setup is required, apart from invoking the DSL in the model. But unless your Redis instance goes down, your data is stored across sessions, and even devices. So a good use case for Kredis is uncritical information that you want to share across device borders, e.g., in a web app and a companion mobile app.

## Case Study: Persist and Restore a Collapsed/Expanded UI State Using Kredis

A typical instance of a good use case for Kredis is when persisting UI state, such as:

- Sidebar open/closed state
- Tree view open/closed state
- Accordion collapsed/expanded state
- Custom dashboard layout
- How many lines of a data table to display

Exemplarily, we will take a look at how to manage the collapsed/expanded state of a `<details>` element.

Let's start out with a fresh Rails app, add `kredis` to the bundle, and run its installer:

```sh
$ rails new ui-state-kredis
$ cd ui-state-kredis

$ bundle add kredis
$ bin/rails kredis:install
```

**Note:** This will create a Redis configuration file in `config/redis/shared.yml`.

For the rest of this article, I will assume that you have a local running Redis instance. On macOS with Homebrew, this is as easy as running:

```sh
$ brew install redis
```

Please consult the [official "Getting Started"](https://redis.io/docs/getting-started/installation/) guide for information on how to install Redis on your operating system.

### User Authentication

We are going to use a `User` model as the entity to store UI state information. To avoid bikeshedding here, let's just use what [Devise](https://github.com/heartcombo/devise) provides out of the box:

```sh
$ bundle add devise
$ bin/rails generate devise:install
$ bin/rails generate devise User
$ bin/rails db:migrate
```

We then create an example user in the Rails console:

```sh
$ bin/rails c

User.create(
  email: "julian@example.com",
  password: "mypassword",
  password_confirmation: "mypassword"
)
```

### Our Example App: An Online Store

To illustrate how Kredis can help persist the state of a complex tree structure, let's pretend we are running an online department store. To this end, we will scaffold `Department` and `Product` models. We include a self join from department to department, to create a two-level nested structure:

```sh
$ bin/rails g scaffold Department name:string department:references
$ bin/rails g scaffold Product name:string department:references
$ bin/rails db:migrate
```

We have to permit null parents, of course, to allow for our tree structure roots:

```diff
  class CreateDepartments < ActiveRecord::Migration[7.0]
    def change
      create_table :departments do |t|
        t.string :name
-       t.references :department, null: false, foreign_key: true
+       t.references :department, foreign_key: true

        t.timestamps
      end
    end
  end
```

Our `Department` and `Product` models are defined as such:

```ruby
class Department < ApplicationRecord
  belongs_to :parent, class_name: "Department", optional: true
  has_many :children, class_name: "Department", foreign_key: "department_id"
  has_many :products
end

class Product < ApplicationRecord
  belongs_to :department
end
```

Finally, we use [faker](https://github.com/faker-ruby/faker) to generate some seed data:

```sh
$ bundle add faker
$ bin/rails c

5.times do
  Department.create(
    name: Faker::Commerce.unique.department(max: 1),
    children: (0..2).map do
      Department.new(
        name: Faker::Commerce.unique.department(max: 1),
        products: (0..4).map do
          Product.new(name: Faker::Commerce.unique.product_name)
        end
      )
    end
  )
end
```

### Scaffolding a Storefront

We'll create a very simple `HomeController` that will act as our shop's storefront.

```sh
$ bin/rails g controller Home index --no-helper
      create  app/controllers/home_controller.rb
       route  get 'home/index'
      invoke  erb
      create    app/views/home
      create    app/views/home/index.html.erb
      invoke  test_unit
      create    test/controllers/home_controller_test.rb
```

We perform a self join on the departments' children to retrieve only those which actually have subdepartments (or, in other words, are our tree's roots):

```ruby
# app/controllers/home_controller.rb

class HomeController < ApplicationController
  def index
    @departments = Department.joins(:children).distinct
  end
end
```

In the index view, we set up a nested tree view using two levels of `<details>` elements for our departments:

```erb
<!-- app/views/home/index.html.erb -->

<% @departments.each do |dep| %>
  <details>
    <summary><%= dep.name %></summary>

    <% dep.children.each do |child_dep| %>
      <details style="margin-left: 1rem">
        <summary><%= child_dep.name %></summary>

        <ul>
          <% child_dep.products.each do |prod| %>
            <li><%= prod.name %></li>
          <% end %>
        </ul>
      </details>
    <% end %>
  </details>
<% end %>
```

Right now we have a tree view of departments with intentionally silly product names that we can explore by opening and closing:

!["Storefront" Catalog](https://blog.appsignal.com/images/blog/2023-02/ui-state-home-controller.png)

We'd like to persist the disclosure state of the individual categories, which we will tend to next.

### Persisting UI State of Categories in Kredis

Here is what we are going to do, step by step:

1.  Add a `kredis_set` called `open_department_ids` to the `User` model. The reason we are using a set here is that it doesn't allow duplicates, so we can safely add and remove our departments.
2.  Create a `UIStateController` that will receive the following params:

    - the `department_id`
    - the `open` state of that department

    It will then add or remove this department to the `kredis_set` for the currently logged-in user.

3.  Create a **Stimulus controller** which will listen for the [toggle](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/details#events) event on the details element and send over the respective payload.

Let's get into it!

Adding said Kredis data structure to the `User` model is as easy as calling `kredis_set` and passing an identifier:

```diff
  # app/models/user.rb

  class User < ApplicationRecord
    # Include default devise modules. Others available are:
    # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
    devise :database_authenticatable, :registerable,
           :recoverable, :rememberable, :validatable

+   kredis_set :open_department_ids
  end
```

Next, we generate a `UIStateController` to receive the UI state updates. Note that we have to configure the generated route to be a `patch` endpoint:

```sh
$ bin/rails g controller UIState update --no-helper --skip-template-engine
      create  app/controllers/ui_state_controller.rb
       route  get 'ui_state/update'
      invoke  test_unit
      create    test/controllers/ui_state_controller_test.rb
```

```diff
  Rails.application.routes.draw do
-   get 'ui_state/update'
+   patch 'ui_state/update'
    get 'home/index'
    resources :products
    resources :departments
    devise_for :users
    # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html

    # Defines the root path route ("/")
    root "home#index"
  end
```

Our first encounter with Kredis' API is in the controller. We can see that it tries to conform to Ruby developers' expectations as closely as possible, so you can add to the set using `<<`, and delete using `remove`.

```rb
# app/controllers/ui_state_controller.rb

class UiStateController < ApplicationController
  def update
    if ui_state_params[:open] == "true"
      current_user.open_department_ids << params[:department_id]
    else
      current_user.open_department_ids.remove(params[:department_id])
    end

    head :ok
  end

  private

  def ui_state_params
    params.permit(:department_id, :open)
  end
end
```

What's happening here is that we toggle the presence of a specific `department_id` in the set based on the `open` param being handed over from the client. To complete the picture, we must write some client-side code to transmit these UI state changes.

We are going to use `@rails/request.js` to perform the actions, so we have to pin it:

```sh
$ bin/importmap pin @rails/request.js
Pinning "@rails/request.js" to https://ga.jspm.io/npm:@rails/request.js@0.0.8/src/index.js
```

In a new Stimulus controller that we'll attach to a specific `<details>` element, we append the department ID and its `open` state to a `FormData` object, and submit it:

```js
// app/javascript/controllers/ui_state_controller.js

import { Controller } from "@hotwired/stimulus";
import { patch } from "@rails/request.js";

export default class extends Controller {
  static values = {
    departmentId: Number,
  };

  async toggle() {
    const body = new FormData();
    body.append("open", this.element.open);
    body.append("department_id", this.departmentIdValue);

    await patch("/ui_state/update", {
      body,
    });
  }
}
```

We edit our view code as proposed, and listen for the `toggle` event of each `<details>` element to trigger the UI state updates:

```diff
  <!-- app/views/home/index.html.erb -->

  <% @departments.each do |dep| %>
-   <details>
+   <details
+     data-controller="ui-state"
+     data-action="toggle->ui-state#toggle"
+     data-ui-state-department-id-value="<%= dep.id %>"
+   >
      <summary><%= dep.name %></summary>
      <% dep.children.each do |child_dep| %>
-       <details style="margin-left: 1rem">
+       <details style="margin-left: 1rem"
+         data-controller="ui-state"
+         data-action="toggle->ui-state#toggle"
+         data-ui-state-department-id-value="<%= child_dep.id %>"
+       >
          <summary><%= child_dep.name %></summary>

          <ul>
            <% child_dep.products.each do |prod| %>
              <li><%= prod.name %></li>
            <% end %>
          </ul>
        </details>
      <% end %>
    </details>
  <% end %>
```

### Rehydrate the DOM Manually

The only component missing to go full circle is _rehydrating_ our DOM to the desired state once the user refreshes the page. We do this manually by adding the `open` attribute to the `<details>` node (if its department ID is present in the Kredis set):

```diff
  <!-- app/views/home/index.html.erb -->

  <% @departments.each do |dep| %>
    <details
      data-controller="ui-state"
      data-action="toggle->ui-state#toggle"
      data-ui-state-department-id-value="<%= dep.id %>"
+     <%= "open" if current_user.open_department_ids.include?(dep.id) %>
    >
      <summary><%= dep.name %></summary>

      <% dep.children.each do |child_dep| %>
        <details style="margin-left: 1rem"
          data-controller="ui-state"
          data-action="toggle->ui-state#toggle"
          data-ui-state-department-id-value="<%= child_dep.id %>"
+         <%= "open" if current_user.open_department_ids.include?(child_dep.id) %>
        >
          <summary><%= child_dep.name %></summary>

          <ul>
            <% child_dep.products.each do |prod| %>
              <li><%= prod.name %></li>
            <% end %>
          </ul>
        </details>
      <% end %>
    </details>
  <% end %>
```

Finally, [here's the result](https://blog.appsignal.com/images/blog/2023-02/manual-rehydration.mp4). Note that the open/closed state of individual tree nodes is preserved over 2 levels.

## Up Next: A Generalized User-local Container for UI State

In the first part of this two-part series, we introduced Kredis and explored how to persist and restore a collapsed/expanded UI state with Kredis.

We used the example of an online department store to highlight how Kredis can persist a complex tree structure's state, before finally manually rehydrating the DOM.

However, this does mean that we have to invent a lot of Kredis keys. Next time, we'll dive into how we can address this with a generalized user-local container for UI state.

Until then, happy coding!

**P.S. If you'd like to read Ruby Magic posts as soon as they get off the press, [subscribe to our Ruby Magic newsletter and never miss a single post](https://blog.appsignal.com/ruby-magic)!**