Simple Things Should Be Simple
Previously on Locally Sourced: The Tailwind book is out. Buy it in ebook or at Amazon. Modern Front-End Development For Rails is in final layout and headed to the printer. This post will make a lot more sense if you're familiar with Hotwire If, for example, you bought a book about it...
Simplicity is Not Simple
To the extent that I have a guiding principle of software development it's this Alan Kay quote:
Simple things should be simple, complex things should be possible.
A related fact is that to the best of my recollection, the most robust empirical finding about programming is that error rate is a function of lines of logic in code no matter whether the code is low-level or high-level. Assuming the lines of code are themselves clear, writing less code is almost always better.
I sometimes think of projects as having a complexity budget, and I don't want to waste any complexity on simple tasks so that I can save up the complexity for the parts of the code that really need it.
Often, the complex parts of the code become clearer after you peel off the simpler parts (sometimes you find there's no complex parts after all). TDD, or any process where you work in small bits, can be great for managing complexity, because working in small bits discourages you from adding a lot of complexity unless you really need it.
On the other hand, this post. Solving complicated problems is fun, and it's often how we show off our skills as developers.
I've been slowly working on a project management tool, and I added a feature with Hotwire that I was going to write about here. But as I put together the write up, I realized that I had made the code significantly more complicated than it needed to be. So I rewrote it in a more native Hotwire style, and got rid of maybe 75% of the code. (Okay, it's twelve lines to two, but still...)
Often when I get in trouble in Hotwire and Stimulus, it's because I'm making things more complicated than they need to be. I catch myself thinking about things from a front-end perspective rather than a Rails perspective. Hotwire encourages and rewards working in a Rails style in a way that's an adjustment from other front-end frameworks even if, like me, you really want to make things work in a Hotwire way.
For Hotwire, you want to think of functionality in terms of conventional Rails server interactions, asking "what if I did this interaction and only changed part of the page in response". This is a very different structure from React, where you think in terms of client-side state and values and updating values locally. The state view is so ingrained that I find myself falling into it even when there's simpler code available if I think in terms of server interaction.
Building an Auto-Update Form
Anyway, the example:
The unit of work in this tool is a "card". Here's the edit feature that automatically submits to the server as the user types a new description and title for a card.
The functionality of the form (separate from the show/hide behavior) is that typing in either the name or description field triggers a form submit. Also, the title form element syncs with the title display element so that the title updates when you hide the form fields.
My first thought was to do this primarily in Stimulus.
The markup for this form code looked like this (taken from the real code):
<%# FIRST DRAFT CODE %>
<div
class="w-4/5 mr-10"
data-controller="autosubmit"
data-autosubmit-url-value="<%= card_url(@card) %>">
<%= hidden_field_tag(
:id,
@card.id,
id: dom_id(@card, "local_id"),
"data-autosubmit-target": "field"
) %>
<%= f.input(
:description,
input_html: {
rows: 5,
"data-autosubmit-target": "field",
"data-action":
"input->autosubmit#typingStarted
input->autosubmit#submit"
}
) %>
</div>
We've got a Stimulus controller called autosubmit
. It's got a url
value set to the eventual update path of the card, and then two targets named field
: a hidden field with the ID of the card, and the description input. Then there are two actions, of which the important one is submit
. (There's a simple_form_for
surrounding all this that is not shown, and we'll talk about the title field later).
The thing that I was hoping to do here that was "clever" was to have the form elements on the page and not an actual form
tag, handling all the form parts in code.
Here's a first pass at the associated controller. (This is a slightly simplified version of the real code.)
import { Controller } from "stimulus";
import { csrfToken } from "../utilities/rails";
export default class AutosubmitController extends Controller {
static targets = ["field"];
static values = { url: String };
setFocusBorder(element, newBorderColor) {
// Implementation not shown for space
}
typingStarted(event) {
this.setFocusBorder(event.target, "yellow-300");
}
async submit(event) {
const formData = new FormData();
this.fieldTargets.forEach((field) => {
formData.append(field.name, field.value);
});
await fetch(this.urlValue, {
method: "PATCH",
headers: {
"X-Requested-With": "XMLHttpRequest",
"X-CSRF-Token": csrfToken(),
credentials: "same-origin",
},
body: formData,
});
this.setFocusBorder(event.target, "green-500");
}
}
The submit
method creates a JavaScript FormData
object, populates it with the keys and values of all the elements that are field
targets of the controller, and then uses fetch
to send that data to the update
action of the controller. It asynchronously waits on the result, does nothing with the result, but makes the border green after it's gotten the result to signal to the user that that the submit is complete.
Extending the Auto Submit Form
This isn't very complex or anything, it's really only a few lines of logic (the live code is a little more complex, because it debounces the form submission but more on that in a bit). But the code does get more complicated when I added in the title form. The autosubmit can get reused as is, but to keep the form element and the display text in sync I added another Stimulus controller.
Here's the markup
<div
class="w-full mr-10 mt-2 -ml-2"
data-controller="autosubmit form-sync"
data-autosubmit-url-value="<%= card_url(@card) %>"
data-form-sync-target-id-value="<%= dom_id(@card, :title) %>">
<%= hidden_field_tag(
:id,
@card.id,
id: dom_id(@card, "local_id"),
"data-autosubmit-target": "field"
) %>
<%= f.input(
:name,
label: false,
placeholder: "Name",
required: true,
autofocus: true,
input_html: {
class: "sm:w-full font-bold text-lg sm:text-black",
"data-autosubmit-target": "field",
"data-form-sync-target": "source",
"data-action":
"input->autosubmit#typingStarted
input->autosubmit#submit
input->form-sync#sync"
}
) %>
</div>
The title markup is similar to the description markup, except there's a second Stimulus controller called form-sync
that has a data value that is a target id, a target called source
and a sync
action that is also triggered when the user types into form box.
The controller is brief, it takes the value of the source and makes it the text of the element at the target id:
import { Controller } from "stimulus";
export default class FormSyncController extends Controller {
static targets = ["source"];
static values = { targetId: String };
sync() {
const element = document.getElementById(this.targetIdValue);
element.textContent = this.sourceTarget.value;
}
}
This is not wildly complex either, though now that I look at it, it could stand for a guard against the element
no being found.
Simplicity is Not Always Simple
This all works, I even wrote a Cypress test to prove it, which we'll cover in the next post. I was really happy with it. It's not a lot, but making the pieces fit together was satisfying.
And I thought, "this will make a great post, I can write about how I built a real feature in Hotwire, and how much easier Hotwire makes things when you try to do normal Rails stuff".
I started to write it, like three times, but the post just wouldn't come together.
Eventually I realized that even this, like, 10 lines of code was overthinking they code, and what I really had was just a standard Rails update returning a Turbo Stream:
This code:
- Gathers together a set of field elements
- Submits them to Rails
- Does something after the return
A regular Rails update (with Turbo Streams):
- Gathers together a set of field elements because they are already in a form
- Submits them to Rails, because that's what forms do
- Does something after the return because that's what Turbo Stream does
Having thought about it, they do seem rather similar.
Here's the new code, starting with the ERB for both fields
<%= simple_form_for(
@card,
html: {"data-controller": "autosubmit",
"data-autosubmit-target": "form"}
) do |f| %>
<%= hidden_field_tag(:id, @card.id, id: dom_id(@card, "local_id")) %>
<% if params[:field] == "title" %>
<div class="w-full mr-10 mt-2 -ml-2">
<%= f.input(
:name,
label: false,
placeholder: "Name",
required: true,
autofocus: true,
input_html: {
class: "sm:w-full font-bold text-lg sm:text-black",
"data-action": "input->autosubmit#typingStarted
input->autosubmit#submit"
}
) %>
</div>
<% end %>
<% if params[:field] == "description" %>
<div class="flex flex-col">
<div class="flex mx-10 mr-20">
<div class="w-4/5 mr-10">
<%= f.input(
:description,
input_html: {
rows: 5,
"data-action": "input->autosubmit#typingStarted
input->autosubmit#submit"
}
) %>
</div>
</div>
</div>
<% end %>
<% end %>
They share a form tag and hidden field, but display their input element separately (the if
statement has to do with how the show/hide behavior works, it's not important right now).
It's similar, but simpler:
- The
autosubmit
only declares one target, the form itself - The hidden field tags don't need to be a target
- The
form_sync
controller is completely gone
I still have two actions for each form, though I think I could combine them if I needed to (which, again, we'll get to when we talk about debouncing).
The submit
action in the controller has gotten way shorter:
submit(event) {
this.formTarget.requestSubmit()
this.setFocusBorder(event.target, "green-500")
}
And, of course, the other controller is just gone, so that's something like 12 lines of logic replaced with 2 lines.
We do need to have the update
action return a Turbo Stream so we can update the title display with the newly edited title. I don't need to change the controller for that to work, I just need a Turbo Stream ERB:
<%= turbo_stream.update(dom_id(@card, :title)) do %>
<%= @card.name %>
<% end %>
That's it, though it does update the title element even when the description is the field being updated. That'd be easy enough to fix if it became a problem.
So What?
This is a simple example, even the "complex" code isn't all that complex, but the simpler code is better. It's shorter, does less weird stuff client side, and depends on existing framework behavior.
The behavior is so simple, honestly, that if I had started with it, I wouldn't have even considered writing 1400 words about it.
The specific lesson to Hotwire is: let the framework do the work for you, Hotwire is there to reward basic Rails behavior.
The general lesson is: simple is (usually) good, at least as a starting point. But complex is easy to fall into, and often feels good and rewarding when you are doing it.
Try not to be overly complex.
If you made it to the end and want to comment on this post, add a comment to the permanent home at http://noelrappin.com/blog/2021/06/simple-things-should-be-simple/
Next time: I'll show some other logistics of this example, including a Cypress test and debouncing the form submit.
Dynamic Ruby is brought to you by Noel Rappin.
Comments and archive at noelrappin.com, or contact me at noelrap@ruby.social on Mastodon or @noelrappin.com on Bluesky.
To support this newsletter, subscribe by following one of these two links: