CableReady::Updatable
Batteries Included Reactivity ✨
Imagine that whenever the state of your application changes (for example, a record is added, deleted, or modified), all connected users receive instant, customized view updates.
The complexity of this situation lies in the fact that many views in an application are dependent on the attributes of the current user.
- Is she the post owner? Then she may delete it, we have to render a "delete" button 🚮.
- Is she a global administrator? Then we have to show some additional statistics 📊.
- Is he an editor? We have to render an "edit" button 📝.
- and so on...
As complicated as this sounds, we want to deliver the pinnacle of developer experience, so we set out to solve this problem.
TL;DR
If you are in a hurry, here's the gist of what you have to do to enjoy the magic 🪄:
- In your model, include the
CableReady::Updatable
module - Call
enable_cable_ready_updates
as a class method in your model, or passenable_cable_ready_updates: true
to ahas_many
association:
class Comment < ApplicationRecord
include CableReady::Updatable
belongs_to :feed
enable_cable_ready_updates
end
class Feed < ApplicationRecord
include CableReady::Updatable
has_many :comments, enable_cable_ready_updates: true
end
- Use the
cable_ready_updates_for
view helper to automatically subscribe to any updates the rendered models receive:
<!-- app/views/feeds/show.html.erb -->
<ul>
<%= cable_ready_updates_for @feed, :comments do %>
<%= render @feed.comments %>
<% end %>
</ul>
<!-- app/views/comments/_comment.html.erb -->
<li>
<%= comment.title %> - created <%= time_ago_in_words(comment.created_at) %> ago
<% if comment.owner == current_user %>
<!-- show dropdown -->
<% end %>
</li>
API Reference
Module
enable_cable_ready_updates(on:, if:)
Registers after_commit
callbacks in the background, which initiate Action Cable pings after each write to the database.
Parameters:
on:
(optional) limits updates to be triggered to a certain action, or a combination (:create, :update, :destroy
)
enable_cable_ready_updates on: :create
enable_cable_ready_updates on: [:update, :destroy]
if:
(optional) a lambda that can be passed to determine whether to deliver updates:
enable_cable_ready_updates if: -> { ready? }
Association Extensions
CableReady::Updatable
provides convenience extensions for Active Record associations:
has_many
You can pass the following arguments to a has_many
association:
enable_cable_ready_updates: true
: This will create a stream identifier so you can subscribe to updates on a collection:
<%= cable_ready_updates_for @feed, :comments do %>
<%= render @feed.comments %>
<% end %>
descendants
: Single Table Inheritance requires us to be explicit about the descendant classes an association could embody.
Suppose we have a class inheritance structure like this:
class Block < ApplicationRecord
belongs_to :section
end
class Comment < Block
end
If we want our users to receive Comment
updates, we would have to specify a has_many
association like this:
class Section < ApplicationRecord
include CableReady::Updatable
has_many :blocks,
enable_cable_ready_updates: true,
descendants: ["Comment"]
end
has_one
You can pass the following arguments to a has_one
:
enable_cable_ready_updates: true
: This will create a stream identifier so you can subscribe to updates on the dependent record:
<%= cable_ready_updates_for @supplier, :account do %>
<%= render @supplier.account %>
<% end %>
has_many_attached
For your convenience, we also provide an extension for an Active Storage has_many_attached
relation:
enable_cable_ready_updates: true
: This will create a stream identifier so you can subscribe to updates on all Active Storage attachments
class Post < ApplicationRecord
include CableReady::Updatable
has_many_attached :images, enable_cable_ready_updates: true
end
<%= cable_ready_updates_for @post, :images do %>
<!-- render images -->
<% end %>
View Helper
cable_ready_updates_for(*keys, url: nil, debounce: nil, only: nil, ignore_inner_updates: false, observe_appearance: false, html_options: {}, &block)
This helper method will render a <cable-ready-updates-for>
custom HTML element that contains all the JavaScript behavior to
- connect to a certain resource via a generated stream identifier for Action Cable
- receive updates from the server and morph the resulting HTML.
Parameters:
keys
: (required) a model reference, or an array splat containing a model reference and an association key, e.g.@post
, or@feed, :posts
url:
: (optional) allows you to specify a different URL to request the updated HTML payload from than the current location.debounce:
: (optional) an integer value denoting the milliseconds to debounce updates (default 20)only:
: (optional) enables you to specify an allow list of model attributes you want to track updates forignore_inner_updates:
(optional), a flag to determine whether to trigger updates for state changes emanating from an inner form submit or reflex (defaultfalse
). Essentially enabling this means do not fire an update for the user who originated the change, which can solve typical race conditions.observe_appearance
: (optional), a flag that determines whether this element should observe its visibility in the viewport. If switched on, this element will ignore updates if it is invisible, i.e. below the fold, or in a hidden browser tab/window. Such an element will default to lazy loading when it first becomes visible, and then start listening for updates. Thus, it optimizes the number of requests that this element possible makes.html_options:
: (optional) a hash of options to pass to the generated<cable-ready-updates-for>
tag, e.g.class
,data: {}
etc.
How It Works
Here's a high-level overview of all the participants in this process, and how they interact:
- It all starts with a database update occurring at the model level. The
CableReady::Updatable
then signals the view via Action Cable that something has changed about it. - The view somehow stores the element in focus, if any.
- Then, the view (or rather, some JavaScript inside it) requests updated content from the controller responsible for the currently active route (a simple GET request).
- The controller goes through its normal request/response cycle and responds with the according HTML.
- In a callback, the view patches the affected portions of the DOM via morphdom.
- The view restores focus.
Advanced Usage
GlobalID POROs
Updatable
can handle POROs that conform to the Global ID protocol. Such POROs only have to declare an id
instance method and a find(id)
class method:
class GlobalIdableEntity
include GlobalID::Identification
include CableReady::Updatable
def id
"fake-id"
end
def self.find(id)
# somehow find and return an instance of this class
end
def fake_update
CableReady::Updatable::ModelUpdatableCallbacks.new(:update).after_commit(self)
end
end
Observe that of course in such a case the ActiveRecord::Callbacks
module is missing, so we have to cater for triggering the included callbacks ourselves. In the example above, this is provided by the fake_update
method.
Skip Updates
Sometimes you have to bulk update records, such as when performing a data migration. Because such tasks can often happen in an unsuited environment (for example, without connection to Redis), it might be preferable to opt out of updates. This can be done by wrapping those CRUD mutations in a skip_cable_ready_updates
block, which is very similar to Active Record's no_touching
implementation:
# Skips broadcasts for all models inheriting from ApplicationRecord:
ApplicationRecord.skip_cable_ready_updates do
Comment.update_all(status: :published)
end
# Skips broadcasts for Comment only:
Comment.skip_cable_ready_updates do
# will not broadcast
Comment.update_all(status: :published)
# will broadcast
Post.update_all(author: nil)
end
Best Practices
Scope to Required Model Attributes
Each update to an element will lead to the server-triggered request/response cycle outlined above. You can reduce the load on your app server if you aggressively scope your updates to a specified subset of model attributes with only:
.
Use Minimal HTML Payloads
One of the major performance bottlenecks regarding Updatable
is the fact that by default it re-fetches the HTML from the current controller action. This can result in large HTML payloads, but can be mitigated if your view is already decomposed into several eager/lazy loaded Turbo Frames (or similar) by specifying a custom url:
parameter.
Use ActionController::ConditionalGet
Changing a lot of models in quick succession (for example, when using belongs_to ... touch: true
) can result in "thundering herds" of HTTP requests being thrown at your app server. Often, though, the HTML payload between those has not changed, which is inefficient and leads to a lot of view rendering (read: server CPU time) going down the drain.
Updatable
does its best to deduplicate and memoize HTML payloads, but it pays off to leverage regular HTTP Caching. The way to accomplish this in Rails is via the ActionController::ConditionalGet
class using either fresh_when
or stale?
:
class ArticlesController < ApplicationController
etag { current_user&.id }
def show
@article = Article.find(params[:id])
fresh_when @article
end
end
This will generate an ETag
header transmitted with your response (basically a hashed value of the resource's GID and updated_at
). The browser will then send the ETag in the next request (using the If-None-Match
header). The server will then compare both new and old ETags, and if nothing changed will respond with a 304 Not Modified
code, omitting the time and CPU intensive view rendering.
Note: Be sure to include any additional scoping information, like the current user's ID in the etag class method (see example above).
Browser Caching
However, this method still occupies the server's resources. If you are feeling audacious, you can also experiment with the expires_in
method, setting it to a few seconds, assuming your model(s) don't change more frequently. This will allow the browser to use its internal cache, not even bugging the server with a request. Be careful though, as you might inadvertently be getting stale content if anything changed on the server in the meantime.
All in all, HTTP caching is a powerful tool that unfortunately a lot of people shy away from, because it can lead to mysterious errors (mostly due to stale content or wrong ETag computation), but it can really pay off if your app servers experience a lot of load. And on top of that, with ActionController::ConditionalGet
, Rails makes setting it up sufficiently straightforward.
Antipatterns
Don't Enable Updates Globally
in ApplicationRecord
(or similar base classes). This will trigger an Action Cable broadcast to all users, giving Redis and your app server a hard time.
Rather, choose to opt in to enable updates on a granular level, model by model.
Don't Wrap Large DOM Trees
A common mistake is to wrap a large portion of your view in an updates_for
tag. This can lead to morph, and focus restoration issues. Prefer wrapping smaller portions of the DOM containing the actually volatile parts.
For example, instead of wrapping a whole _post.html.erb
partial, you could only wrap the updated date and the body:
<!-- BAD ❌ -->
<%= cable_ready_updates_for @post do %>
<%= @post.updated_at %>
<%= simple_format @post.body %>
<% end %>
<!-- GOOD 🙌 -->
<%= cable_ready_updates_for @post, only: :updated_at do %>
<%= @post.updated_at %>
<% end %>
<%= cable_ready_updates_for @post, only: :body do %>
<%= simple_format @post.body %>
<% end %>
Troubleshooting
Updates don't work or are rendered in the wrong place
Most probably your browser console also shows a warning:
Update aborted due to insufficient number of elements. The offending url is ...
This happens when the HTML payload retrieved by Updatable contains less elements with the same identifier as compared to the current view. In that case, Updatable can get confused regarding order of operations.
Typically, what triggers this is lazy loaded content (a lazy loaded Turbo Frame, or a Futurism element) inside the DOM tree that contains more <cable-ready-updates-for>
elements with the same identifier. Updatable cannot resolve any lazy loaded content (it would have to emulate a whole browser engine to do that) inside one of its elements, hence if there is a discrepancy between the amount of <cable-ready-updates-for>
elements before and after the update, it assumes something has gone wrong.
TLDR: Make sure you don't accidentally wrap any lazy loaded content in your updated blocks that might cause conflicts.
Debugging
CableReady::Updatable
comes with handy diagnostic console outputs that you can enable when initializing the client side module:
import CableReady from 'cable_ready'
import consumer from '../channels/consumer'
CableReady.initialize({ consumer, debug: true })