Two related capabilities live here because they are so often used together:

  • RealtimeFunicular::Cable, an ActionCable-compatible WebSocket client, for live updates (chat, notifications, presence).
  • Stores — a declarative DSL over IndexedDB for persisting client-side data (drafts, caches, feeds), with TTL, coordinated clearing, and optional Cable integration so a store can keep itself in sync from a channel.

A chat feature is the canonical example: messages arrive over Cable, and a Store caches them so a returning user sees history instantly.

Tutorial: realtime with Cable

Create a consumer pointed at your Rails cable endpoint, subscribe to a channel, and handle incoming messages by patching state. Clean up on unmount:

class ChatComponent < Funicular::Component
  def initialize_state
    { messages: [], input: "" }
  end

  def component_mounted
    @consumer = Funicular::Cable.create_consumer("/cable")
    @subscription = @consumer.subscriptions.create(channel: "ChatChannel", room: "lobby") do |data|
      patch(messages: state.messages + [data]) if data
    end
  end

  def handle_send
    @subscription.perform("speak", message: state.input)  # calls ChatChannel#speak
    patch(input: "")
  end

  def component_unmounted
    @subscription&.unsubscribe
    @consumer&.disconnect
  end
end

subscriptions.create(channel:, **params) mirrors ActionCable; perform(action, data) invokes the matching method on your Rails channel. The consumer reconnects automatically. The Rails side is ordinary ActionCable:

# app/channels/chat_channel.rb
class ChatChannel < ApplicationCable::Channel
  def subscribed
    stream_from "chat_#{params[:room]}"
  end

  def speak(data)
    ActionCable.server.broadcast("chat_#{params[:room]}",
      { id: SecureRandom.uuid, user: current_user.username, content: data["message"] })
  end
end

Always unsubscribe and disconnect in component_unmounted — a dangling subscription keeps receiving messages in the background.

Tutorial: persisting with a Store

A Store persists data in IndexedDB, partitioned by a scope. Subclass Singleton (one value per scope) or Collection (an ordered list per scope):

# app/funicular/stores/draft_store.rb
class Funicular::DraftStore < Funicular::Store::Singleton
  database   "funicular_drafts"
  scope      :channel_id
  cleared_on :logout
  expires_in 60 * 60 * 24   # optional 24h TTL
end

Get a scope with .where(...) and read/write through it:

draft = Funicular::DraftStore.where(channel_id: 1)
draft.value = "Hello"       # persisted to IndexedDB
draft.value                 # => "Hello"  (nil if missing/expired)
draft.delete

Stores open IndexedDB lazily on first use — no init! needed.

A Store that syncs over Cable

A Collection can subscribe to a channel itself, so caching and realtime are one declaration. The handler block runs with self bound to the scope, so replace / append / remove mutate that scope’s cache:

class Funicular::MessageCache < Funicular::Store::Collection
  database "funicular_message_cache"
  scope    :channel_id
  limit    100
  key      ->(m) { m["id"] }

  subscribes_to "ChatChannel",
                params: ->(s) { { channel: "ChatChannel", channel_id: s.channel_id } } do |data, _scope|
    case data["type"]
    when "initial_messages" then replace(data["messages"] || [])
    when "new_message"      then append(data["message"])
    when "delete_message"   then remove(data["message_id"])
    end
  end
end

In the component, read the cache for an instant first paint, subscribe to changes, then activate the Cable subscription:

def select_channel(channel)
  @cache&.unsubscribe!
  @cache&.off_change(@cb_id) if @cb_id

  @cache  = Funicular::MessageCache.where(channel_id: channel.id)
  patch(messages: @cache.all)                       # cached history, immediately
  @cb_id  = @cache.on_change { |snap| patch(messages: snap) }
  @cache.subscribe!                                 # live updates from here on
end

def component_unmounted
  @cache&.unsubscribe!
  @cache&.off_change(@cb_id) if @cb_id
end

Reference

Cable

consumer = Funicular::Cable.create_consumer("/cable")  # relative or ws(s):// URL
sub = consumer.subscriptions.create(channel: "ChatChannel", room: "lobby") { |data| ... }
sub.perform("speak", message: "Hi")   # invoke a Rails channel action
sub.unsubscribe
consumer.disconnect

Compatible with ActionCable subscriptions, broadcasting, perform actions, auto-reconnect, and ping/pong keepalive. Prefer focused channels over one catch-all channel, validate all incoming data on the server, and use a key: on list items rendered from messages for efficient re-rendering.

Store types and DSL

Funicular::Store::Singleton — one value per scope: value, value= (setting "" deletes), delete, present?, expired?.

Funicular::Store::Collection — ordered list per scope: all, replace(arr), append(item), remove(id), last, last_id, size, clear, same_tail?(other), expired?.

DSL Description
database "name" IndexedDB database name (required)
kvs_store "name" Object store within the database (default "kv")
scope :key / scope :a, :b Keys that partition data
limit n (Collection) cap on items
order :append / :prepend (Collection) insertion order
key ->(item) { ... } (Collection) extract an item’s id
expires_in seconds TTL (lazy-deleted on read)
cleared_on :event Register for Store.dispatch(:event)
cable_url "/path" ActionCable endpoint (default "/cable")
subscribes_to "Channel", params: { } Embed Cable handling (block runs with self == Scope)
source ModelClass / belongs_to :name Decorative annotations (no runtime behavior)

Scope API

.where(...) returns a memoized Scope (same kwargs return the same instance, which matters for on_change identity). Common to both types:

cb_id = scope.on_change { |snapshot| ... }
scope.off_change(cb_id)
scope.subscribe!     # requires subscribes_to
scope.unsubscribe!
scope.subscribed?

Note scope keys use strict equality: where(channel_id: 1) and where(channel_id: "1") are different Scope instances (though stored data is shared, since storage keys are stringified).

Coordinated clearing

Register stores with cleared_on :event and wipe them all at once — for example on logout:

Funicular::Store.dispatch(:logout)   # clears every store registered for :logout

Pass a block to cleared_on to override the default full wipe with custom logic.

In the demo

funicular-demo:

Tags: