Stateful, reactive, server-rendered views for Ruby, built with Phlex and Rage.
Note
Pulse is in active development. Its API may change.
Pulse keeps Phlex component instances alive for the lifetime of a WebSocket connection. Browser events call ordinary Ruby methods, transient UI state stays in the component, and updates are rendered as HTML and morphed into the page.
# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
def show
article = Article.find(params[:id])
render phlex: Articles::Card, article: article # <-- render the component
end
end
# app/views/articles/card.rb
class Articles::Card < Pulse::Component
def initialize(article:)
@article = article
@expanded = false
end
def view_template
div do
button(click: :toggle_details) do # <-- set up the event handler
@expanded ? "Show less" : "Show more"
end
p { @expanded ? @article.body : @article.body.truncate(150) }
end
end
def toggle_details
@expanded = !@expanded # <-- update local state and re-render
end
endClicking the button calls toggle_details on that exact Articles::Card instance, then Pulse re-renders and patches the component in place. @expanded is a plain instance variable: it stays in the component for as long as the user keeps the page open.
No database column, query parameter, JSON API, or hand-written JavaScript is needed to remember whether the article is expanded.
- State is instance variables. Instance variables hold the state, and public methods handle events.
- Views are Phlex components. Composition, inheritance, private methods, and ordinary Ruby all work as expected.
- One fiber per connection. Rage runs each WebSocket connection in its own fiber. Components live in that fiber.
- Works with your controllers. Links and forms still go to regular controller actions. Use components for view logic and controllers for business logic, or build everything in components. Both work.
Pulse preserves standard Phlex syntax. Event handlers are element attributes such as click: :toggle, and key: is available when rendering nested components whose identity must survive a parent re-render.
Call pulse_scripts once in the component that renders the full HTML document:
class Layout < Pulse::Component
def view_template
doctype
html do
head do
title { "My application" }
end
body do
main { yield }
pulse_scripts
end
end
end
endThe helper loads Pulse's fingerprinted client bundle. The client opens the WebSocket, enhances same-origin navigation, and applies HTML updates from the server.
Pulse uses Rage's phlex renderer:
class ArticlesController < ApplicationController
def index
render phlex: Articles::Index, articles: Article.order(id: :desc)
end
endThe rendered class can compose a layout and other components in normal Phlex code:
class Articles::Index < Pulse::Component
def initialize(articles:)
@articles = articles
end
def view_template
render Layout.new do
div do
h1 { "Articles" }
@articles.each do |article|
render Articles::Card.new(article: article), key: article.id
end
end
end
end
end- A normal HTTP request is routed to a Rage controller and rendered with Phlex.
- The Pulse client opens one WebSocket for the browser tab.
- Pulse renders the current route again inside the connection's fiber, creating the live component tree.
- Component events are dispatched to methods on the retained instances.
- The changed component renders HTML, which the client morphs into the existing DOM.
- Links, forms, redirects, and history navigation are routed through the same controllers over the connection.
Phlex supplies the view objects and rendering syntax. Rage supplies the connection handling and fiber-based concurrency. Pulse connects them with event dispatch, component identity, subscriptions, and HTML updates.
Event handlers are public methods defined on a Pulse::Component. After a handler returns, Pulse automatically replaces that component unless the handler performed another targeted update itself.
class Search < Pulse::Component
def initialize
@query = ""
end
def view_template
input(
type: "search",
value: @query,
input: :set_query,
placeholder: "Search articles…"
)
end
def set_query(event)
@query = event.value
end
endPulse supports click, input, change, submit, keydown, and keyup handlers. A handler can take an optional event argument:
def search(event)
@query = event.value
end| Method | Returns |
|---|---|
event.value |
the element's value |
event.checked? |
whether a checkbox or radio is checked |
event.form_data |
parsed form fields |
Business operations don't have to live in components. Same-origin links and forms are sent through the WebSocket and dispatched to the same Rage routes and controllers as ordinary HTTP requests.
# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
def create
article = Article.create!(title: params[:title], body: params[:body])
redirect_to "/articles/#{article.id}"
end
end
# app/views/articles/create.rb
class Articles::Create < Pulse::Component
def view_template
render Layout.new do
div do
h1 { "New article" }
form(method: "post", action: "/articles") do # <-- goes to ArticlesController#create
label(for: "title") { "Title" }
input(type: "text", id: "title", name: "title")
label(for: "body") { "Body" }
textarea(id: "body", name: "body")
button(type: "submit") { "Create article" }
end
end
end
end
endThe controller doesn't know anything about Pulse. It reads params, does its job, then renders or redirects as usual, and Pulse updates the page without a full reload.
Pulse can preserve unchanged nested component instances when their parent re-renders. In a collection, pass a stable key to render so Pulse can associate each new declaration with the existing component:
class Articles::MostViewed < Pulse::Component
def initialize(articles:)
@articles = articles
end
def view_template
div do
@articles.each do |article|
render Articles::Card.new(article: article), key: article.id
end
end
end
endUse Pulse::Store to share state between components. Components that watch a store re-render automatically when it changes:
# app/stores/theme_store.rb
class ThemeStore < Pulse::Store
attribute :value, default: "light"
def toggle
self.value = value == "light" ? "dark" : "light"
end
end
# app/views/layout.rb
class Layout < Pulse::Component
def initialize
@theme = watch(ThemeStore)
end
def view_template
doctype
html do
body do
render Navbar.new
main(class: "theme-#{@theme.value}") do # <-- read the value
yield
end
pulse_scripts
end
end
end
end
# app/views/navbar.rb
class Navbar < Pulse::Component
def initialize
@theme = watch(ThemeStore)
end
def view_template
nav { button(click: :toggle_theme) { "Toggle theme" } }
end
def toggle_theme
@theme.toggle # <-- update the value
end
endClicking the button in Navbar re-renders both Navbar and Layout. Each connection gets its own store instance, so one user's theme doesn't affect other users.
watch also works with Active Record models. Call Pulse.broadcast when a model changes, and every component watching that record re-renders:
# app/models/article.rb
class Article < ApplicationRecord
after_commit :broadcast_upvotes, if: :saved_change_to_upvotes?
private
def broadcast_upvotes
Pulse.broadcast(self)
end
end
# app/views/articles/card.rb
class Articles::Card < Pulse::Component
def initialize(article:)
@article = watch(article)
end
def view_template
div do
h1 { @article.title }
p { @article.body }
p { "Upvotes: #{@article.upvotes}" }
end
end
endPulse reloads the record and re-renders every connected component watching it. watch also accepts a block that runs with the reloaded record before the component is replaced:
@article = watch(article) do |article|
@flash = "#{article.title} was updated"
endComponents can inspect the current page, update query parameters and local storage, or navigate through the live connection:
page.query[:theme] = "dark"
page.local_storage[:theme] = "dark"
page.navigate("/articles")| Method | Description |
|---|---|
page.path |
the current path |
page.url |
the current path with the query string |
page.query |
query parameters; writing to it updates the URL |
page.local_storage |
browser local storage, kept across page loads and reconnects |
page.navigate(url) |
navigates to url |
An event normally ends with an automatic replace, but components can choose another operation:
replace # re-render this component in place
append(target: "notifications") # add to the end of #notifications
prepend(target: "notifications") # add to the start of #notifications
remove # remove this component from the pageThe application in demo/ is a small article CMS demonstrating controller-backed forms, transient state, stores, model broadcasts, keyed nested components, and targeted updates.
rake install
cd demo
bundle install
rage db:prepare
rage sOpen http://localhost:3000 in two tabs. Expanding an article is local to each connection, while publishing or unpublishing it persists to the database and broadcasts to the other tab.
Bug reports, design feedback, and pull requests are welcome. Because Pulse is still experimental, please open an issue before investing in a large change so the approach can be discussed first.
Pulse is available under the MIT License.