DEV Community

Mansa Keïta
Mansa Keïta

Posted on • Updated on

Using Turbo Streams with Kredis

When using Turbo Streams, we usually want to trigger WebSockets updates from our domain models using the Turbo::Broadcastable methods included in ActiveRecord.

But what if we want to add a feature which only uses Redis to persist data? How can we broadcast Turbo streams when the value of a Redis key changes?

Let's anwser those questions by making a simple live counter with Turbo Streams and Kredis.

Making a counter with Turbo Streams and Kredis

Let's generate a Rails 7 app without ActiveRecord (and with Tailwind just to make our counter look good):

$ rails new hotwire-counter --css tailwind --skip-active-record
$ cd hotwire-counter
Enter fullscreen mode Exit fullscreen mode

To use Kredis, we need to uncomment gem "kredis" in our Gemfile and install it:

$ bundle
$ bin/rails kredis:install
Enter fullscreen mode Exit fullscreen mode

Let's generate a controller:

$ rails g controller counter show
Enter fullscreen mode Exit fullscreen mode

And define our actions:

# app/controllers/counter_controller.rb
class CounterController < ApplicationController
  def show
    render :show, locals: {
      counter_count: counter_count.value
    }
  end

  def increment
    counter_count.increment

    respond_to do |format|
      format.turbo_stream { render_partial_update }

      format.html { redirect_to root_url }
    end
  end

  def decrement
    counter_count.decrement

    respond_to do |format|
      format.turbo_stream { render_partial_update }

      format.html { redirect_to root_url }
    end
  end

  private
    # We use Kredis to store and retrieve the counter's state
    def counter_count
      @counter_count ||= Kredis.counter "counter:count"
    end

    def render_partial_update
      render turbo_stream: turbo_stream.update("counter-count",
        counter_count.value
      )
    end
end
Enter fullscreen mode Exit fullscreen mode

Let's add our routes:

Rails.application.routes.draw do
  root to: "counter#show"

  post "/increment", to: "counter#increment", as: :increment
  post "/decrement", to: "counter#decrement", as: :decrement
end
Enter fullscreen mode Exit fullscreen mode

Let's update app/views/counter/show.html.erb:

<div class="flex flex-col items-center mx-auto my-0 mb-4 text-lg gap-2">
  <div>
    <span id="counter-count">
      <%= counter_count %>
    </span>
  </div>

  <div class="flex gap-2">
    <%= button_to "+", increment_path, class: "px-8 py-4 text-white bg-black rounded" %>
    <%= button_to "-", decrement_path, class: "px-8 py-4 text-white bg-black rounded" %>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

Let's start the server:

$ bin/dev
Enter fullscreen mode Exit fullscreen mode

Now we should be able to use our counter in the browser:
Rendering Turbo streams

Broadcasting Turbo streams

Since we're not using ActiveRecord, we won't be able to use the Turbo::Broadcastable methods to broadcast updates.

But this won't be a problem because we can use the Turbo::StreamsChannel directly with methods like Turbo::StreamsChannel.broadcast_update_to (defined in the Turbo::Streams::Broadcasts module).

It works with the turbo_stream_from helper used in the views to subscribe to the Turbo::StreamsChannel and receive updates from specific streams.

Let's try to use Turbo::StreamsChannel.broadcast_update_to in our controller to broadcast updates:

# app/controllers/counter_controller.rb
class CounterController < ApplicationController
  ...
  def increment
    ...
    respond_to do |format|
      format.turbo_stream do 
        render_partial_update

        broadcast_update
      end
      ...
    end
  end

  def decrement
    ...
    respond_to do |format|
      format.turbo_stream do 
        render_partial_update

        broadcast_update
      end
      ...
    end
  end

  private
    ...
    # This will broadcast the following Turbo stream:
    # <turbo-stream action="update" target="counter-count" >
    #   <template>counter_count.value</template>
    # </turbo-stream>
    def broadcast_update
      Turbo::StreamsChannel.broadcast_update_to "counter",
        target: "counter-count"
        content: counter_count.value
    end
end
Enter fullscreen mode Exit fullscreen mode

We also need to use turbo_stream_from in app/views/counter/show.html.erb to receive the updates:

<%= turbo_stream_from "counter" %>
...
Enter fullscreen mode Exit fullscreen mode

Now let's open our page in two separate tabs:
Broadcasting Turbo streams

Nice, it worked!

But hold on! There's a problem with our approach. Can you figure out what it is?

Our controller shouldn't be responsible for broadcasting page changes. That's because they can't be transmitted if we use the counter directly from the back end. It means we won't see the result on the page if we increment the count from the console, for example.

Can you think of a better solution?

A better approach would be to model our counter by including Kredis::Attributes and using the kredis_counter macro with an after_change callback:

# app/models/counter.rb
class Counter
  include Kredis::Attributes

  kredis_counter :count,
    key: "counter:count",
    after_change: :broadcast_update

  class << self
    def count
      new.count
    end
  end

  def broadcast_update
    Turbo::StreamsChannel.broadcast_update_to "counter",
      target: "counter-count",
      content: count.value
  end
end
Enter fullscreen mode Exit fullscreen mode

Now that we moved the broadcasting logic in the Counter model, we can broadcast updates whenever the count changes. It means we can update the count from the console and see the result on the page.

Let's continue by refactoring the #counter_count method in our controller:

# app/controllers/counter_controller.rb
class CounterController < ApplicationController
  ...
  private
    def counter_count
      @counter_count ||= Counter.count
    end
end
Enter fullscreen mode Exit fullscreen mode

Now let's open the console and update the count:
Broadcasting Turbo streams from Kredis

Et voilà, there you have it!

Wrap up

We can use the Turbo::StreamsChannel with Kredis::Attributes to broadcast Turbo streams when the value of a Redis key changes.

Oldest comments (2)

Collapse
 
superails profile image
Yaroslav Shmarov • Edited

The really interesting stuff starts with "Can you think of a better solution?". I love your example with the custom model a lot. Thanks for the read!

Collapse
 
mansakondo profile image
Mansa Keïta

@superails Thanks for reading! I'm glad that you liked it :)