'stimulus.js live update field outside of controller
On a rails 6 installation, I have the following:
Controller:
# app/controllers/foo_controller.rb
def bar
@items = [["firstname", "{{ FIRSTNAME }}"], ["lastname", "{{ LASTNAME }}"], ["company", "{{ COMPANY }}"]]
end
View:
# app/views/foo/bar.html.erb
<p>Quia <span data-field="firstname">{{ FIRSTNAME }}</span> quibusd <span data-field="firstname">{{ FIRSTNAME }}</span> am sint culpa velit necessi <span data-field="lastname">{{ LASTNAME }}</span> tatibus s impedit recusandae modi dolorem <span data-field="company">{{ COMPANY }}</span> aut illo ducimus unde quo u <span data-field="firstname">{{ FIRSTNAME }}</span> tempore voluptas.</p>
<% @items.each do |variable, placeholder| %>
<div data-controller="hello">
<input
type="text"
data-hello-target="name"
data-action="hello#greet"
data-field="<%= variable %>"
value="<%= placeholder %>">
</div>
<% end %>
and the relevant stimulus code (vanilla JS):
//app/javascript/controllers/hello_controller.js
import { Controller } from "stimulus"
export default class extends Controller {
static targets = [ "name" ]
greet() {
var elements = document.body.querySelectorAll('[data-field="' + this.nameTarget.dataset.field + '"]');
for (var i = 0; i < elements.length; i++) {
elements[i].innerText = this.nameTarget.value;
};
}
}
Now, as you might have guessed, the idea is to generate one <input> field per item from the @items hash, pre-filled with the relevant value and "linked" with a <span>, which it updates on value change. So far, everything works.
Here's my issue though. This part is plain old dirty vanilla js, which doesn't feel too 'stimulusy':
var elements = document.body.querySelectorAll('[data-field="' + this.nameTarget.dataset.field + '"]');
for (var i = 0; i < elements.length; i++) {
elements[i].innerText = this.nameTarget.value;
};
Surely there's some way to improve this. Any suggestion as to how to refactor this code in a more elegant way would be most welcome.
Thanks!
Solution 1:[1]
An approach would be to have two controllers, one for the 'thing that will change the content' (let's call this content) and another for the 'thing that will show any updated content somewhere else' (let's call this output).
Once you set up two controllers, it becomes a bit easier to reason about them as being discrete. One does something when a value updates from user interaction and the other should so something when it knows about an updated value.
Stimulus recommends cross controller coordination with events. JavaScript event passing is a powerful, browser native, way to communicate across elements in the DOM.
First, let's start with the simplest case in HTML only
- In general, it is good to think about the HTML first, irrespective of how the content is generated on the server side as it will help you solve one problem at a time.
- As an aside, I do not write Ruby and this question would be easier to parse if it only had the smallest viable HTML to reproduce the question.
- Below we have two
divelements, one sits above and is meant to show thenamevalue inside theh1tag and theemailin theptag. - The second
divcontains a twoinputtags and these are where the user will update the value. - I have hard-coded the 'initial' data as this would come from the server in the first HTML render.
<body>
<div
class="container"
data-controller="output"
data-action="content:updated@window->output#updateLabel"
>
<h1 class="title">
Hello
<span data-output-target="item" data-field="name">Joe</span>
</h1>
<p>
Email:
<span data-output-target="item" data-field="email">[email protected]</span>
</p>
</div>
<div data-controller="content">
<input
type="text"
data-action="content#update"
data-content-field-param="name"
value="Joe"
/>
<input
type="text"
data-action="content#update"
data-content-field-param="email"
value="[email protected]"
/>
</div>
</body>
Second - walk through the event flow
- Once an
inputis updated, it will fire theconten#updateevent on change. - The
data-content-field-paramis an Action Parameter that will be available inside theevent.paramsgiven to the class methodupdateon thecontentcontroller. - This way, the one class method has knowledge of the element that has changed (via the event) and the field 'name' to give this when passing the information on.
- The
outputcontroller has a separate action to 'listen' for an event calledcontent:updatedand it will listen for this event globally (at thewindow) and then call its own methodupdateLabelwith the received event. - The
outputcontroller has targets with the nameitemand each one has the mapping of what 'field' it should referent in a simpledata-fieldattribute.
Third - create the controllers
- Below, the
ContentControllerhas a singleupdatemethod that will receive any fired input element's change event. - The value can be gathered from the event's
currentTargetand the field can be gathered via theevent.params.field. - Then a new event is fired with the
this.dispatchmethod, we give it a name ofupdatedand Stimulus will automatically append the class namecontentgiving the event namecontent:updated. As per docs - https://stimulus.hotwired.dev/reference/controllers#cross-controller-coordination-with-events - The
OutputControllerhas a target of nameitemand then a methodupdateLabel updateLabelwill receive the event and 'pull out' the detail given to it from theContentController's dispatch.- Finally,
updateLabelwill go through each of theitemTargetsand see if any have the matching field name on that element's dataset and then update theinnerTextwhen a match is found. This also means you could have multiple 'name' placeholders throughout this controller's scoped HTML.
class ContentController extends Controller {
update(event) {
const field = event.params.field;
const value = event.currentTarget.value;
this.dispatch('updated', { detail: { field, value } });
}
}
class OutputController extends Controller {
static targets = ['item'];
updateLabel(event) {
const { field, value } = event.detail;
this.itemTargets.forEach((element) => {
if (element.dataset.field === field) {
element.innerText = value;
}
});
}
}
Solution 2:[2]
An alternate approach is to follow the Publish-Subscribe pattern and simply have one controller that can both publish events and subscribe to them.
- This leverages the recommended approach of Cross-controller coordination with events.
- This approach adds a single controller that will be 'close' to the elements that need to publish/subscribe and is overall simpler to the first answer.
PubSubController - JS code example
- In the controller below we have two methods, a
publishwhich will dispatch an event, and asubscribewhich will receive an event and update the contoller's element. - The
valueused by this controller is akeywhich will serve as the reference for what values matter to what subscription.
class PubSubController extends Controller {
static values = { key: String };
publish(event) {
const key = this.keyValue;
const value = event.target.value;
this.dispatch('send', { detail: { key, value } });
}
subscribe(event) {
const { key, value } = event.detail;
if (this.keyValue !== key) return;
this.element.innerText = value;
}
}
PubSubController - HTML usage example
- The controller will be added to each input (to publish) and each DOM element you want to be updated (to subscribe).
- Looking at the
inputs you can see that they have the controllerpub-suband also an action (defaults to triggering when the input changes) to fire the publish method. - Each
inputalso contains a reference to itskey(e.g email or name). - Finally, the two spans that 'subscribe' to the content are triggered on the event
pub-sub:sendand pass the event to thesubscribemethod. These also have a key.
<body>
<div class="container">
<h1 class="title">
Hello
<span
data-controller="pub-sub"
data-action="pub-sub:send@window->pub-sub#subscribe"
data-pub-sub-key-value="name"
>Joe</span
>
</h1>
<p>
Email:
<span
data-controller="pub-sub"
data-action="pub-sub:send@window->pub-sub#subscribe"
data-pub-sub-key-value="email"
>[email protected]</span
>
</p>
</div>
<div>
<input
type="text"
data-controller="pub-sub"
data-action="pub-sub#publish"
data-pub-sub-key-value="name"
value="Joe"
/>
<input
type="text"
data-controller="pub-sub"
data-action="pub-sub#publish"
data-pub-sub-key-value="email"
value="[email protected]"
/>
</div>
</body>
Sources
This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.
Source: Stack Overflow
| Solution | Source |
|---|---|
| Solution 1 | LB Ben Johnston |
| Solution 2 | LB Ben Johnston |
