Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Isomorphic View Components: Re-imagining the Rails front-end

Isomorphic View Components: Re-imagining the Rails front-end

The Rails front-end got a big upgrade recently. Hotwire, Turbo, Turbo Native, etc have gotten a lot of attention - and deservedly so. However, React is still the most popular choice for developing web UIs, and even the newest Rails tech doesn't really stack up. The Rails community needs an answer, preferably one built on web technologies, that lets us write Ruby. This talk covers my own journey with Rails and how I came to build Neato, an experimental front-end framework that lets Rails devs write component-oriented, reactive components in Ruby and run them in the browser.

Given at the SF Bay Area Ruby Meetup on May 16th, 2024.

Cameron Dutro

May 18, 2024
Tweet

Transcript

  1. My journey to GitHub • Sep - Dec 2010: Fluther

    • 2011 - 2014: Twitter • 2014 - 2020: Lumos Labs • 2020 - 2021: Salesforce (Quip) • 2021 - now: GitHub Rails renaissance
  2. Rails renaissance • Hotwire • Turbo, Turbo Native • RailsWorld

    announced • ViewComponent was having a moment
  3. ViewComponent A framework for creating reusable, testable & encapsulated view

    components, built to integrate seamlessly with Ruby on Rails. “ ” viewcomponent.org
  4. ViewComponent example # app/components/greeting_component.rb class GreetingComponent < ViewComponent::Base def initialize(people:)

    @people = people end end <%# app/components/greeting_component.html.erb %> <div> <% @people.each do |person| %> <%= render(NameComponent.new( first_name: person[:first_name]) last_name: person[:last_name] )) %> <% end %> </div>
  5. Rux # app/components/greeting_component.rux class GreetingComponent < ViewComponent::Base def initialize(people:) @people

    = people end def call <div> {@people.map do |person| <NameComponent first-name={person[:first_name]} last-name={person[:last_name]} /> end} </div> end end Star 385
  6. The story continues • February 2024: I ported GitHub’s global

    user nav drawer to React • It’s a fantastic experience; React is awesome
  7. The story continues • March 2024: Sin City Ruby conference

    in Las Vegas • Jason Charnes live-codes an interactive site builder using Rails front-end tooling • Company tried to use Stimulus, Hotwire, Turbo, etc, but ultimately wrote the feature in React • Painful watching Jason switch between so many fi les, connecting things with IDs, etc
  8. ViewComponent interactivity • Render server-side • Use Web Components to

    attach dynamic behavior • Use github/catalyst to reduce Web Component boilerplate
  9. Web Component example import {controller, target} from '@github/catalyst' @controller class

    PrimerTextFieldElement extends HTMLElement { @target inputElement: HTMLInputElement @target validationElement: HTMLElement @target validationErrorIcon: HTMLElement toggleValidationStyling(isError: boolean): void { if (isError) { this.validationElement.classList.remove('FormControl-inlineValidation--success') } else { this.validationElement.classList.add('FormControl-inlineValidation--success') } this.validationSuccessIcon.hidden = isError this.validationErrorIcon.hidden = !isError this.inputElement.setAttribute('invalid', isError ? 'true' : 'false') } setError(message: string): void { this.toggleValidationStyling(true) this.setValidationMessage(message) this.validationElement.hidden = false } }
  10. Limitations of Web Components • Imperative, jquery-like code • Find

    elements, add/remove classes, hide/show, etc • Easy to make a mistake or miss an edge case • Lots of event listener gotchas • Hard to re-render since there’s no template • Di ffi cult to synchronize data model and view layer
  11. React • Mature; large ecosystem • Declarative template, event handlers,

    refs, etc • Much less error-prone code, easier to reason about • Data binding (updating state updates the view) • Simply magical - no more querySelector().setAttribute() • Template (JSX) lives inside the component
  12. React Problems • Dynamism makes it more di ffi cult

    to write accessible components • No such thing as “static” content • Tends to take over the whole page • Large bundle sizes • Performance concerns on low-end devices • You don’t get to write Ruby
  13. Takeaways • Components are good • Reactivity is good •

    Declarative is better than imperative
  14. Isomorphic view components What if we could render view components

    in the browser? “ ” me after Sin City Ruby
  15. My dream class TodoComponent def call <div> <p>{@name}</p> <p>{@duration}</p> <Button

    click={-> { set_state(edit: true) }}> Edit </Button> </div> end end
  16. My dream class TodoComponent def call <div> {if @edit <TextField

    name="name" /> <TextField name="duration" /> else <p>{@name}</p> <p>{@duration}</p> <Button click={-> { set_state(edit: true) }}> Edit </Button> end} </div> end end
  17. My dream class TodoComponent def call <div> {if @edit <TextField

    name="name" /> <TextField name="duration" /> <Button click={-> { save_changes }}> Save </Button> else <p>{@name}</p> <p>{@duration}</p> <Button click={-> { set_state(edit: true) }}> Edit </Button> end} </div> end private def save_changes Net::HTTP.post_form(...) end end
  18. View components in the browser • Need a Ruby runtime

    • Need some way to update the view when state changes • Need a way for Ruby to call JavaScript functions and wrap JavaScript objects
  19. Ruby runtimes • ruby.wasm • MRI Ruby compiled to Web

    Assembly (WASM) • Large download (~50mb) • Opal.js • Required transpiler step • Sort of a large footprint last time I checked (~400k) + transpiled code
  20. Prior art • Volt • Appears to be unmaintained •

    Hyperstack • Personally not a fan of the syntax • Built on top of React
  21. Garnet.js • TypeScript implementation of YARV, the Ruby virtual machine

    • No transpilation - runs Ruby code directly • Smaller footprint (~95k) + Ruby code • Uses the new Prism parser
  22. Garnet.js • Passes the test suites from several gems •

    Small but capable subset of the Ruby standard library • Decent performance
  23. Neato • Experimental front-end framework for Rails • Uses Rux

    for templating • Compiles templates to tagged template literals • Uses Garnet to run Ruby code • Uses Lit for rendering and state management
  24. Lit is a simple library for building fast, lightweight web

    components. At Lit's core is a … base class that provides reactive state, scoped styles, and a declarative template system that's tiny, fast and expressive. “ ” lit.dev/docs
  25. • Lightweight: 5kb • Built on Web Components • Supports

    isolating CSS styles via browser-native shadow DOM • Uses tagged template literals for templating • “You can build just about any kind of web UI with Lit” - lit.dev
  26. TodoListComponent class TodoListComponent include Neato::Component state :todos def initialize(todos:) @todos

    = todos end def call <table> <tbody> {@todos.map do |todo| <TodoComponent .todo={todo} /> end} </tbody> </table> end end
  27. TodoComponent class TodoComponent include Neato::Component state :todo, :errors, :edit refs

    :name_input, :duration_input def call <tr> <td> {if @edit <> <TextField .value={@todo[:name]} .ref="name_input" /> {if @errors.include?(:name) <p class="text-red-600">Name {@errors[:name].first}</p> end} </> else @todo[:name] end} </td> </tr> … end end
  28. TodoComponent class TodoComponent def call ... <td> {if @edit <Button

    @click={-> { save_changes }}> Save </Button> else <Button @click={-> { set_state(edit: true) }}> Edit </Button> end} </td> ... end end
  29. TodoComponent class TodoComponent private def save_changes response = Neato.fetch("/todos/#{@todo[:id]}", {

    method: 'PUT', headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: Neato::URLSearchParams.new( "todo[name]": name_input.value, "todo[duration_mins]": duration_input.value ) }) if response.ok? set_state(todo: response.json[:todo], edit: false, errors: {}) else @todo[:name] = name_input.value @todo[:duration_mins] = duration_input.value set_state( errors: response.json[:errors] ) end end end