Why is the Controller "target" attribute a space-delimited list?

When I first started looking into Target attributes, I expected them to follow the Class attributes where the name of the “Thing” was in the attribute itself. So, where classes use:

data-my-controller-activated-class="is-active"

I somewhat expected the Target attribute for thing to be like:

data-my-controller-thing-target

But, I see that it is instead like:

data-my-controller-target="thing"

… and, in fact can be:

data-my-controller-target="thing other-thing third-thing"

So, my question is really about the list of target names, and what that enables. Or, to be less pedantic, why would a developer want to give a single target element multiple names (especially since they are in the same controller)? This functionality must enable some kind of use-case; but, I’m not seeing anything about it discussed in the documentation.

Imagine a form with two buttons, save and delete. On click of either button I disable all form buttons to prevent disable clicking. On delete I add a css class to the form to animate it disappearing before the turbo-stream response removes it.

Delete button includes button target and delete target
Save button includes button target

So, are you saying you’d have something like this:

<button type="submit" name="action" value="save" data-my-controller-target="button save">
    Save
</button>
<button type="submit" name="action" value="delete" data-my-controller-target="button delete">
    Delete
</button>

… and then you would be able to reference this.buttonTargets and this.saveTarget kind of thing?

Yes, something like that. Although I have never used this.

Yeah, it’s very curious. I’ll have to keep my eye out for examples that might use this.

I’m doing this on one of my controllers! My code is weird and messy, and won’t make sense out of context, but the short version is that I have two select dropdowns within one controller scope, but an action (a response to a dispatched event) is supposed to programmatically change the selected option on just one of them.

Unless I rewrite to use two controllers and dispatch yet another event (or use outlets), I need two targets on the same node to make it work. So probably strictly not necessary, but in my case it simplifies the code.

1 Like

Ah, I see. So in some cases, you need to target both Selects; and, in other cases, you only need to target one. So, I’m assuming you have something like:

<select data-my-controller-target="select"> ... </select>
<select data-my-controller-target="select special-select"> ... </select>

I can see how that would be helpful. Thanks for the insight.

Here is some code. It’s a simplified case without form labels (accessibility issues), but illustrates the use case.

Both dropdowns set a display: none on filterable nodes that doesn’t contain the option value as class name. But the second one contains more actions (an option to change the dropdown programmatically if location.href is “/events#theatre”), so it needs a way to let the controller method (dropdownChange) affect only one of the targets.

<div data-controller="category-filter">
  <nav>
    <div>
      <form>
        <select name="genre" id="genre" data-category-filter-target="source" data-action="change->category-filter#createStyle
        change->category-filter#filter class="filter">
          <option value="">Choose event type</option>
          <option value="performance">Performance</option>
          <option value="conference">Conference</option>
          <option value="exhibition">Exhibition</option>
        </select>

      </form>
    </div>
    <div>
      <form>
        <select name="category" id="category" data-category-filter-target="source hashchanger" data-action="change->category-filter#createStyle
                          change->category-filter#filter
                          change->category-filter#hashChange
                          turbo:load@document->category-filter#dropdownChange
                          hashchange@window->category-filter#dropdownChange" class="filter">
          <option value="">Choose genre</option>
          <option value="theatre">Theatre</option>
          <option value="dance">Dance</option>
          <option value="opera">Opera</option>
        </select>
      </form>
    </div>
  </nav>

  <article>
    <table>
      <thead>
        <tr>
          <th>Dates</th>
          <th>Event</th>
          <th>Genre</th>
        </tr>
      </thead>
      <tbody>
        <% @events.each do |event| %>
          <tr data-category-filter-target="filterable" class='<%= "#{event.genre} #{event.type}" %>'>
            <td>
              <%= event.dates %>
            </td>
            <td>
              <%= link_to event.title, events_event_path(event.id) %>
            </td>
            <td>
              <%= event.genre %>
            </td>
          </tr>
        <% end %>
      </tbody>
    </table>
  </article>
</div>

It’s not beautiful, but it works, and the controller is quite simple.

1 Like

This is super interesting! I see that you are calling multiple controller methods based on the same action (ie, change triggers createStyle(), filter(), and hashChange()). In the Angular world, I probably would have encapsulated that all into one method like, handleChange(); and, then had that method call those other three methods behind the scenes. So, it’s cool to see how other people go about breaking up the logic.

Also, thank you for including a hashchange@window event - that springs to mind all kinds of interesting functionality. As I mentioned in another thread, I come from the Angular world, and in Angular we can inject a Router service that gives us that kind of insight; but, in Hotwire, I had no idea how I might change the DOM in response to things like the hash; but, seeing this connects a lot of dots :muscle:

Cool that it was useful! I got some inspiration from Matt Swanson’s blog post Writing better Stimulus controllers. It’s about Stimulus 1.0, but most of the general advice is still good.

I wrote the category-filter controller two years ago and I know more now, so some of this could probably be simplified. (createStyle() is there just to write a few lines of CSS dynamically, and I should just move this into my core stylesheet.) But the core concept (using one controller to target two nodes with one method and one node with another) is sound, I think.

This controller is actually an outlier in my app, because as a general rule I prefer controllers to be as small as possible (per Matt’s advice). Then I attach different controllers (sometimes more than one on the same node) whenever needed to add behaviours to parts of the DOM. It’s actually possible that the category filter controller could be split in two or three with the help of custom events or outlets, but it’s working quite well for the time being.

Ha ha, I’ve read that article like 3 or 4 times so far at different points in my journey. The more I learn, the more I come back to it with more context. I do really like the idea of having small controller surface areas.

One thing I’ve been toying with in the back of my head (in terms of small controllers) is adding additional form fields on-the-fly. Imagine a list of Pets, and a button for “Add another pet” that just adds another input. At first, I thought a “Form Controller” would makes sense (this is the Angular way). But, now, I’m thinking can I make some sort of “Template Appender Controller” that does nothing but take a <template>, clone it, and add it to a container? Then, I could use that in a number of places.

I’m not saying my idea is correct - only that I’ve been trying hard to think about the “behaviors” and not about the UIs, per say.

I’ve noticed developers coming to hotwire who spent some time building SPA’s seem to feel a phobia for sending requests to the backend (myself included). As a result, they write lots of javascript code to avoid sending requests to the backend.

One of the main motivations for avoiding sending requests to the backend is to save server resources in order to help make the application run faster for all users. This is the SPA way. I asked this question early on when I first began experimenting with hotwire in 2020. The irony is, after more than 2 years building applications with hotwire I can now say my applications run much faster with hotwire than they ever did with an SPA framework.

We try and suppress the urge to limit requests to the backend. We step back and ask ourselves first: how would I build this without javascript? This usually reveals a better pattern for building a solution.

This last week we made the mistake of building a new feature with a hotwire first approach and the results turned out overly complicated. We then stepped back and asked ourselves: how would we build it without any javascript? A better and less complicated solution revealed itself with this one question. We then built it to work without javascript, and then enhanced it with javascript.

Using the last solution as an example, how would you build this without javascript?

  1. The genre form would contain an action targeting the same page and a submit button. After the user selects the option they click the submit button.
  2. The page then reloads with the genre option still selected and the category form with a populated select, an action on the form and a submit button.

Now that it’s working without javascript I can “sprinkle” a little javascript to enhance the experience to make it easier for the user (they don’t have to click “submit” to get the second form if they have javascript).

  1. Use stimulus to submit itself on select change.
  2. Add a css class to hide the submit buttons by default, but unhide it if they do not have javascript enabled (class="hidden no-js:inline"). Note: In every project I have a javascript snippet which flags the DOM for pages which do (or do not) have javascript, so this css class just works without any JS.

At this point with turbo running you might be “good” and require no additional enhancements. To determine if more work is required, some questions you might ask yourself:

  1. Does the user experience a major/jarring scroll position change after submit?
  2. Do we want to update the page without updating the browser history?
  3. Does the backend or frontend HTML view contain too much logic and I want to separate the concerns?

If answering “yes” to at least of the above questions, I then reach for a turbo-frame or turbo-stream element to enhance the user experience or simplify the logic.

Either way, the amount of javascript to make it work well for the user is kept to a minimum and the experience for the user is the same or better than adding more javascript.

2 Likes

@tleish I really like this approach—asking yourself if you can build it without Hotwire first. I’m actually trying to follow this kind of mentality in my learnings. As I’ve been digging into Hotwire, I’m trying to create little demos for myself over on GitHub: ColdFusion + Hotwire Demos. In that code, the first thing I do when I start a new demo (usually by copy/pasting the previous demo) is go into the main page layout and comment out the <script> tag. Then, I try to build the demo using just the traditional GET and POST requests. Once I have that working, I add the <script> tag back in and try to fix whatever started breaking.

The most frustrating part of this is that getting the ColdFusion server-side rendering to work is relatively easy - it’s the sprinkling in Turbo that is the hard part :rofl:

Great post, @tleish, and you’re completely right that my example could be made to work without javascript. This specific example predates my using Turbo, so javascript was basically all I had when it came to interactivity. Also, it’s reused in an type/instant search filtering instance, which makes non javascript solutions slightly more involved - but far from impossible.

Instead, my non javascript solution is actually hiding the dropdowns and just making the entire table visible without filtering options at all. Acceptable, in my opinion! (Obviously this won’t work as well with thousands of records or a real site search, but it works quite well for filtering up to 300 (my case) if you do fragment (or low level) caching or static pages.)

I’ll admit there are exceptions where this does approach does not, but for us it works the majority of the time.