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
To use Kredis, we need to uncomment gem "kredis"
in our Gemfile and install it:
$ bundle
$ bin/rails kredis:install
Let's generate a controller:
$ rails g controller counter show
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
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
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>
Let's start the server:
$ bin/dev
Now we should be able to use our counter in the browser:
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
We also need to use turbo_stream_from
in app/views/counter/show.html.erb
to receive the updates:
<%= turbo_stream_from "counter" %>
...
Now let's open our page in two separate tabs:
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
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
Now let's open the console and update the count:
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.
Top comments (2)
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!
@superails Thanks for reading! I'm glad that you liked it :)