Forms that Spark Joy

Adrien Siami - Paris.rb - 2025-01-07

Links

Historical fact

The first recorded usage of forms dates back to ancient Mesopotamia around 3000 BCE. The Sumerians used clay tablets to create forms for various purposes, including trade, accounting, and legal agreements. ChatGPT

What are forms?

A way to :

  • collect data from users
  • validate that data
  • process that data

Some forms spark joy

Some don't.

👋 I'm Adrien

Adrien Siami

  • prev Getaround, Ecotable, now @ Kelvin (Future40 😎)
  • Rails fan since 2009
  • Turbo in prod since beta 🤘
  • Working on YAMLFish - a simple i18n tool


Socials: @Intrepidd

Let's talk about form_with

It can be used without a model

            
              <%= form_with url: "/search", method: :get do |form| %>
                <%= form.label :query, "Search for:" %>
                <%= form.search_field :query %>
                <%= form.submit "Search" %>
              <% end %>
            
          
  • No validations
  • Pre-filling is handled in the view
  • Logic has to be done in the controller, not easily re-usable

But most times, forms are tied to models

            
              <%= form_with model: @book do |form| %>
                
<%= form.label :title %> <%= form.text_field :title %>
<%= form.label :author %> <%= form.text_field :author %>
<%= form.submit %> <% end %>

Causing complicated validations

            
              class Person < ApplicationRecord
                validates :email, uniqueness: true, on: :account_setup
                validates :age, numericality: true, on: :account_setup
              end
            
          

            
              person = Person.new(age: 'thirty-three')
              person.valid? # true
              person.valid?(:account_setup) # false
            
          

leading to security issues

            
              class SomeController < ApplicationController
                def user_params
                  params.require(:user).permit(:first_name, :last_name)
                end
              end

              class SomeOtherController < ApplicationController
                def user_params
                  params.require(:user).permit(:first_name, :last_name, :role)
                end
              end
            
        

and making it hard to work with multiple models

            
              class Person < ApplicationRecord
                has_many :addresses, inverse_of: :person
                accepts_nested_attributes_for :addresses
              end

              class Address < ApplicationRecord
                belongs_to :person
              end
            
          

and making it hard to work with multiple models

              
                class Person < ApplicationRecord
                  has_many :addresses, inverse_of: :person
                  accepts_nested_attributes_for :addresses
                end

                class Address < ApplicationRecord
                  belongs_to :person
                end
              
            

😱

Form Objects to the rescue

Form objects mimics Rails models to extract all form logic into a single object, and benefit from all the magic even without a model.

  • Treat forms as first class citizens
  • Encapsulate the data processing logic
  • Separate attributes and validations
  • Make it easy to test

✨HyperActiveForm✨

My implementation of a form object for Rails

  • Uses ActiveModel::Attributes and ActiveModel::Validations
  • No dependencies
  • ~50 LOC
  • Used internally in production since 2020

Intrepidd/hyperactiveform

            
              class ProfileForm < ApplicationForm
              end
            
          
            
              class ProfileForm < ApplicationForm
                proxy_for User, :@user

                attribute :first_name
                attribute :last_name
                attribute :birth_date, :date

                validates :first_name, :last_name, :birth_date,
                          presence: true

                def setup(user)
                  @user = user
                  self.first_name = user.first_name
                  self.last_name = user.last_name
                  self.birth_date = user.birth_date
                end

                def perform
                  @user.update!(
                    first_name: first_name,
                    last_name: last_name,
                    birth_date: birth_date
                  )
                end
              end
            
          
            
              class UsersController < ApplicationController
                def edit
                  @form = ProfileForm.new(user: current_user)
                end

                def update
                  @form = ProfileForm.new(user: current_user)
                  if @form.submit(params[:user])
                    redirect_to root_path, notice: "Profile updated"
                  else
                    render :edit, status: :unprocessable_entity
                  end
                end
            
          
            
              <%= form_with(model: @form) do |f| %>
                <%= f.text_field :first_name %>
                <%= f.text_field :last_name %>
                <%= f.date_field :birth_date %>

                <%= f.submit %>
              <% end %>
            
          

No model? No problem

            
              class UserSearchForm < ApplicationForm
                attribute :name
                attribute :email
                attribute :min_age, :integer

                attr_reader :results

                def perform
                  @results = User.all

                  if name.present?
                    @results = @results.where(name: name)
                  end
                  if email.present?
                    @results = @results.where(email: email)
                  end
                  if age.present?
                    @results = @results.where("age >= ?", age)
                  end

                  true
                end
              end
            
        

Hotwire magic ✨

Allowing for dynamic forms : attributes are added, removed, updated on the fly without reloading the page.

The idea 💡

  • All display logic is handled on the back-end
  • When a field is changed, the form is "refreshed" from the server
  • Turbo goodies help make it feel smooth (morphing and scroll preservation)

But how to refresh the form?

  • Submit the form automatically on field change thanks to Stimulus
  • Pass a special param so the controller knows it's a refresh
  • Refresh the form with the new data, and render the view again

You may already have this stimulus controller

            
              export default class extends Controller {
                static targets = ['form']

                submit () {
                  if (!this.hasFormTarget) return
                  this.formTarget.requestSubmit()
                }
              }
            
          
Or be using AutoSubmit from Stimulus Components ✨

Let's add a small touch

            
              export default class extends Controller {
                static targets = ['form']

                submit () {
                  if (!this.hasFormTarget) return
                  this.formTarget.requestSubmit()
                }
              }
            
          

Let's add a small touch

            
              export default class extends Controller {
                static targets = ['form']

                submit () {
                  if (!this.hasFormTarget) return
                  this.formTarget.requestSubmit()
                }

                refresh() {
                  const hiddenInput = Object.assign(document.createElement('input'), {
                    type: 'hidden',
                    name: 'refresh_form',
                    value: '1'
                  })
                  this.formTarget.appendChild(hiddenInput)
                  this.formTarget.setAttribute('novalidate', 'novalidate')
                  this.submit()
                }
              }
            
          

The display logic is in the view

            
              <%= f.check_box :show_advanced,
                  data: {
                    action: "change->submit-form#refresh"
                  }
              %>

              <% if @form.show_advanced %>
                <% # ... %>
              <% end %>
            
        

Catch the refresh on the back-end

            
              def update
                if params[:refresh_form]
                  @form.assign_form_attributes(params[:user])
                  render :edit, status: :unprocessable_entity
                  return
                end

                if @form.submit(params[:user])
                  redirect_to root_path, notice: "Profile updated"
                else
                  render :edit, status: :unprocessable_entity
                end
              end
            
          
            
              def update
                @form = UserForm.new(current_user)

                return if intercept_refresh(@form, :edit, params[:user])

                if @form.submit(params[:user])
                  redirect_to root_path, notice: "Profile updated"
                else
                  render :edit, status: :unprocessable_entity
                end
              end

              private

              def intercept_refresh(form, template, form_params)
                if params[:refresh_form]
                  form.assign_form_attributes(form_params)
                  render template, status: :unprocessable_entity
                  true
                end
              end
            
          

Don't forget to use morphing and scroll preservation

            
              <%= turbo_refreshes_with(method: :morph, scroll: :preserve) %>

              <%= form_with(model: @form) do |f| %>
                <%# ... %>
              <% end %>
            
          
This will make sure focus states and scroll positions are preserved, making the refresh feel more natural.

Pro-tip: style based on aria-busy

Turbo adds an aria-busy attribute to the form while it's busy.
You can use this to your advantage to make the form feel more responsive.
            
              #form {
                transition: opacity 0.2s ease-in;
              }
              #form[aria-busy="true"] {
                opacity: 0.6;
              }

              // TailwindCSS:
              // aria-busy:opacity-60 transition-[opacity] duration-200 ease-in
            
          

Live demo time 🤞

forms-demo app.
Source: Intrepidd/forms-demo

Adrien, you are making back & forth calls to the server to enable a form button, you are a criminal.😱🚨
Deal with it

Addressing common doubts

  • bandwidth-wise, the overhead is minimal, especially compared to the React world
  • we could do the same full client-side with Stimulus, but it leads to very specific controllers
  • the lag has to be taken into account, but in most cases it's not a big deal

TLDR; use responsibly

It's a trade-off between convenience and performance.

For me, the developer experience is greatly improved, and the downsides are negligible for most use cases.

Thanks !

Presentation & all links available here :
QR code