Counter
The smallest live island. Here a count is kept on the server, and a button on the page adds one to that count. When the count changes, the server renders the partial again and sends it back over the wire as HTML.
Counter
count
0
doubled 0
# frozen_string_literal: true
# Generated by hibiki:rails:island — a working mini-example: one state,
# one derived, one action, one broadcasting effect.
class CounterChannel < ApplicationCable::Channel
include Hibiki::Rails::Channel
def build_graph
@count = Hibiki::State.new(0)
doubled = Hibiki::Derived.new { @count.value * 2 }
Hibiki::Effect.new do
broadcast_replace target: "counter_display",
partial: "app/recipes/counter/counter_display",
locals: { count: @count.value, doubled: doubled.value }
end
end
# Public methods are client-invocable actions; keep helpers private.
def increment = @count.value += 1
end
<%# Reactive island generated by hibiki_rails %>
<% cid = local_assigns.fetch(:cid) { SecureRandom.uuid } %>
<%= tag.div(class: "mx-auto w-full max-w-xs overflow-hidden rounded-3xl bg-base-100 shadow-2xl shadow-indigo-500/10 ring-1 ring-base-content/10",
**hibiki_island(CounterChannel, cid:)) do %>
<%= turbo_stream_from "counter", cid %>
<div class="relative isolate overflow-hidden bg-linear-to-br from-sky-500 via-indigo-500 to-violet-600 px-6 py-8 text-center text-white">
<div class="absolute -top-16 -right-10 -z-10 size-48 rounded-full bg-white/15 blur-2xl"></div>
<div class="absolute -bottom-20 -left-12 -z-10 size-56 rounded-full bg-indigo-900/30 blur-3xl"></div>
<%# Placeholder only: the graph's first broadcast replaces this. %>
<%= render "app/recipes/counter/counter_display", count: 0, doubled: 0 %>
</div>
<p class="p-4"><%= tag.button("+1", class: "btn btn-lg w-full rounded-2xl border-0 bg-linear-to-br from-sky-500 to-indigo-600 text-white shadow-lg shadow-indigo-500/30 transition hover:scale-[1.02] active:scale-95", **on(:increment)) %></p>
<% end %>
<%# The root id is the replacement key. Keep in sync with channel effect's target. %>
<div id="counter_display">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-white/70">count</p>
<p class="mt-1 text-7xl font-black leading-none tracking-tight tabular-nums"><%= count %></p>
<p class="mt-5 inline-flex items-baseline gap-1.5 rounded-full bg-white/20 px-3 py-1 text-sm text-white/80">
doubled <strong class="font-bold text-white tabular-nums"><%= doubled %></strong>
</p>
</div>
The card above shows a count and its double. Press +1 and both numbers go up, even though we never set up the hook in our code. Everything works and is made possible by the reactivity Hibiki brings.
How it fits together
The count lives in a channel. In Rails, a channel is the server
side of an Action Cable connection: an object that stays alive while a
page is open and answers messages from it. A Hibiki channel is one of
those with a small graph of values inside it, built automatically, when the page connects, from
the three reactive primitives: state, derived and effect.
CounterChannel builds this
graph in build_graph.
-
A state holds a value and remembers who read it.
Here
@countis astatethat starts at 0. -
A derived is a value worked out from other values, and worked
out again when one of them changes. Here
doubledis aderivedwhose block reads@countand returns the count times two. -
An effect is a block of code that runs once, and runs again
whenever a value it read has changed. Here the counter's effect reads
@countanddoubled, renders the display partial with both, and sends the HTML to the page withbroadcast_replace, which swaps it in for the element whose id iscounter_display.
On the rendered component, the button calls increment, one of the channel's defined
actions. Every public method on a Hibiki channel is an action the
page may call. When the button is pressed, the browser
sends increment over the connection, and the channel runs it
on the graph's own thread. The "magic" happens in this order:
-
@countgoes from 0 to 1 and tells its readers. -
doubledread it, so it is marked stale. - The effect read both, so it runs again, renders the card's display partial with 1 and 2, and broadcasts it.
- Finally, Turbo receives the broadcast in this tab and replaces the card.
The island on the page
The part of a page that talks to a channel is an island. A Hibiki island is just one normal HTML
element, with the channel's controls and output inside it. Here the partial _counter.html.erb is the
counter's island (the island can exist anywhere on the page and doesn't have to be inside a partial).
-
hibiki_island(CounterChannel, cid:)marks the root element. It stamps the data attributes that the packaged Stimulus controller reads to subscribe toCounterChannel, and the channel builds its graph when the subscription arrives. Thecidis a fresh id for this page load. -
turbo_stream_from "counter", cidis the stream the effect broadcasts to. It is named after the channel and the cid, to prevent a card from being sent to another visitor. -
on(:increment)on the button sends theincrementaction when the button is clicked. Theon()helper comes with Hibiki and supports different types of events. Click is the default event type.
During the initial page load, the island renders _counter_display.html.erb once with
its initial states (zeros). Then the effect's first run sends the same state from the server, replacing the initial rendering.
Generate it in your own app
The three files are what the hibiki_rails island generator writes:
bin/rails g hibiki:rails:install
bin/rails g hibiki:rails:island counter
The generator writes plain markup with minimal class attributes and none of the Tailwind class names. The island shown on this page is an example of how you can customize the island to your liking.
The counter we showed here has one state, one derived, one effect and one action: it is the smallest graph that shows all four. The next recipe (Todo List) shows a more advanced setup you can do with Hibiki.