Using Hotwire with ViewComponents
OpenProject uses Hotwire alongside ViewComponents to build dynamic user interfaces. This combination allows us to create interactive features while maintaining a component-based architecture.
The approach below is meant to be a thin abstraction layer on top of Hotwire’s Turbo Streams to make them easier to use with a component based UI architecture built on ViewComponents.
Key Concepts
Component Setup
- Components must include
OpTurbo::Streamablemodule - Requires
component_wrapperin templates for turbo-stream updates - Can specify insert targets for append/prepend operations
Controller Integration
- Controllers must include
OpTurbo::ComponentStreammodule, which provides methods for turbo-stream operations:update_via_turbo_streamreplace_via_turbo_streamremove_via_turbo_streammodify_via_turbo_streamappend_via_turbo_streamprepend_via_turbo_streamadd_before_via_turbo_streamrender_error_flash_message_via_turbo_streamupdate_flash_message_via_turbo_streamscroll_into_view_via_turbo_stream
- Uses
respond_with_turbo_streamsto handle responses
Example
Imagine we have a component that renders a list of journals for a work package.
This is the index component:
class JournalIndexComponent < ApplicationComponent
include OpTurbo::Streamable # include this module
def initialize(work_package:)
super
@work_package = work_package
end
attr_reader :work_package
# optional:
# modifier to determine if the insert target should be modified
# relevant for append or prepend operations
def insert_target_modified?
true
end
def insert_target_modifier_id
"work-package-journals"
end
# ...
end
with the following template:
<%=
component_wrapper do # wrapper is required for turbo-stream updates!
flex_layout do |journals_index_wrapper_container|
journals_index_wrapper_container.with_row do
flex_layout(id: insert_target_modifier_id) do |journals_index_container|
work_package.journals.each do |journal|
journals_index_container.with_row do
render(JournalShowComponent.new(journal:))
end
end
end
end
journals_index_wrapper_container.with_row do
render(JournalNewComponent.new(work_package:))
end
end
end
%>
And this is the show component:
class JournalShowComponent < ApplicationComponent
include OpTurbo::Streamable # include this module
def initialize(journal:)
super
@journal = journal
end
attr_reader :journal
# ...
end
with the following template:
<%=
component_wrapper do # wrapper is required for turbo-stream updates!
render(border_box_container()) do |border_box_component|
# ...
end
end
%>
With this setup, turbo-stream updates can be sent from a rails controller:
class JournalController < ApplicationController
include OpTurbo::ComponentStream # include this module!
# ...
def update
journal = Journal.find(params[:id])
journal.update(journal_params) # in real life this would be done through a service obviously ;)
# update the journal show component
update_via_turbo_stream(
component: JournalShowComponent.new(journal: journal)
)
# respond with turbo streams which were collected in the @turbo_streams variable behind the scenes
# handy if this method is just meant to respond to turbo-stream requests
respond_with_turbo_streams
end
def create
journal = Journal.create(journal_params) # in real life this is done through a service obviosuly ;)
if journal.errors.empty?
# append the new model to the index component
# prepend is also possible
append_via_turbo_stream(
component: JournalShowComponent.new(journal: journal),
target_component: JournalIndexComponent.new(work_package: @work_package)
)
# Note: the target_component does not get rendered
# the instatiation is just required for the turbo-stream generation
# you can use multiple turbo_stream methods in one controller action
# e.g. update the new component to render an initial form
update_via_turbo_stream(
component: JournalNewComponent.new(work_package: journal.work_package)
)
else
# optionally set a turbo status for the response
@turbo_status = :bad_request
# trigger a flash message via turbo-stream
# more on this here lookbook/pages/patterns/flash_banner
update_flash_message_via_turbo_stream(
message: journal.errors.full_messages.join(", "),
scheme: :danger
)
end
# respond with turbo streams which were collected in the @turbo_streams variable behind the scenes
# handy if this method is just meant to respond to turbo-stream requests
respond_with_turbo_streams
end
# ...
end
Mixing turbo-streams and other responses
TODO: Discuss the below example
class JournalController < ApplicationController
include OpTurbo::ComponentStream # include this module!
# ...
def update
# ...
respond_to do |format|
format.html do
# ...
end
format.turbo_stream do
update_via_turbo_stream(
component: JournalShowComponent.new(journal: journal)
)
render turbo_stream: turbo_streams, status: :ok
end
end
end
# ...
end
Usage alongside Primer Forms/Buttons
TODO: is turbo: true required here?
<%=
component_wrapper do
# ...
primer_form_with(
model: journal,
method: :put,
data: { turbo: true, turbo_stream: true }, # add this!
url: journal_path(id: journal.id)
) do |f|
# ...
end
# ...
end
%>
Rendering of a cancel button to remove the edition form. The button calls the cancel_edit action on the controller. The cancel_edit action sends a turbo stream replace to replace the journal edit form with the journal view.
<%=
component_wrapper do
# ...
render(Primer::Beta::Button.new(
href: cancel_edit_journal_path(journal.id),
data: { turbo: true, turbo_stream: true } # add this!
)) do
t("button_cancel")
end
# ...
end
%>
Requesting turbo-streams within Stimulus controllers
If for some reason you need to request a turbo-stream programmatically from within a Stimulus controller, you can use the TurboRequestsService to do so.
TODO: Discuss the TurboRequestsService API
import { Controller } from '@hotwired/stimulus';
import { TurboRequestsService } from 'core-app/core/turbo/turbo-requests.service';
export default class IndexController extends Controller {
private turboRequests:TurboRequestsService;
async connect() {
const context = await window.OpenProject.getPluginContext();
this.turboRequests = context.services.turboRequests;
}
private async someMethod() {
// this method will automatically handle the turbo-stream response and thus trigger the DOM updates
const response = await this.turboRequests.request(someUrl, {
method: 'GET',
});
// for optional further processing of the stream html and response headers:
console.log(response.html, response.headers);
}
}