Multi Controller Communication
Keep on Learning!
If you liked what you've learned so far, dive in! Subscribe to get access to this tutorial plus video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeWhen we confirm the modal, the delete form now submits via Ajax. That's cool... but it created a problem. The row just sits there! And actually, it's more complicated than simply removing that row. The total at the bottom also needs to change... and if this is the only item in the cart, we need to show the "your cart is empty" message.
Let me add a couple items to the cart... to keep things interesting.
Need to Update the Page? Make an HTML Ajax Request!
You might be tempted to start trying to do all of this in JavaScript. Removing the row would be pretty easy... though, we would need to move the data-controller from the form to the div around the entire row so we have access to that element.
But updating the total and - worse - printing the "your cart is empty" message without duplicating the message we already have in Twig... is starting to look pretty annoying! Is there an easier way?
There is! And it's delightfully refreshing. Stop trying to do everything in JavaScript and instead rely on your server-side templates. So instead of removing the row... and changing the total... and rendering the "your cart is empty" message all in JavaScript, we can make a single Ajax call to an endpoint that returns the new HTML for the entire cart area. Then we replace the cart's content with the new HTML and... done!
The Case for Two Controllers
But wait a second. Go look at the template. Right now, our stimulus_controller() is on the form element... so each row has its own Stimulus controller. To be able to replace the HTML for the entire cart area, does this mean we need to move the data-controller attribute to the <div> that's around the entire cart section? Because... in order to set that innerHTML on this element, it does need to live inside our Stimulus controller. So, do we need to move our controller here?
The answer is... no: we do not need to move the data-controller attribute onto this div. Well, let me clarify. We could move the data-controller from our form up to the div that's around the cart area.
If we did that, we would need to do some refactoring in our controller. Specifically, instead of referencing this.element to get the form, we would need to reference event.currentTarget. So that's kind of annoying... but no huge deal... and it would give us the ability to replace the entire HTML of the cart area after making the Ajax request.
So why aren't we going to do this? The real reason I don't want to move the controller up to this top level element is because, well... it doesn't really make sense for our submit-confirm controller to both show a confirmation dialog on submit and make an Ajax call to refresh the HTML for the cart area. Those are two very different jobs. And if we did smash the code for making the Ajax call into this controller, we would no longer be able to reuse the submit-confirm controller for other forms on our site... because it would now hold code specific to the cart area.
So what's the better solution? First, keep submit-confirm exactly how it is. It does its small job wonderfully. I am so proud. Second, add the new functionality to a second controller.
Creating the Second Controller
Check it out: in assets/controllers/ create a new cart-list_controller.js. I'll cheat and copy the top of my submit-confirm controller... paste it here, but we don't need sweetalert. Add the usual connect() method with console.log()... a shopping cart.
| import { Controller } from 'stimulus'; | |
| export default class extends Controller { | |
| connect() { | |
| console.log('🛒'); | |
| } | |
| } |
The job of this controller will be to hold any JavaScript needed for the cart area. So basically, any JavaScript for this <div>. In practice, this means its job will be to replace the cart HTML with fresh HTML via an Ajax request after an item is removed.
In templates/cart/cart.html.twig, find the <div> around the entire cart area... here it is. Add {{ stimulus_controller() }} and pass cart-list.
| // ... lines 1 - 2 | |
| {% block body %} | |
| // ... lines 4 - 13 | |
| <div | |
| // ... line 15 | |
| {{ stimulus_controller('cart-list') }} | |
| > | |
| // ... lines 18 - 89 | |
| </div> | |
| // ... lines 91 - 93 | |
| {% endblock %} | |
| // ... lines 95 - 96 |
Ok! Let's make sure that's connected. Head over and... refresh. Got it.
Controllers Are Independent
In Stimulus, each controller acts in isolation: each is its own little independent unit of code. And while it is possible to make one controller call a method directly on another, it's not terribly common.
But in this case, we have a problem. In our new controller, we need to run some code - make an Ajax request to get the fresh cart HTML - only after the other controller has finished submitting the delete form via Ajax. Somehow the submit-confirm controller needs to notify the cart-list controller that its Ajax call has finished.
So the big question is: how do we do that?
Dispatching a Custom Event
By doing exactly what native DOM elements already do: dispatch an event. Yup, we can dispatch a custom event in one controller and listen to it from another. And, the stimulus-use library we installed earlier has a behavior for this! It's called useDispatch. You can dispatch events without this behavior... this just makes it easier.
Tip
Stimulus itself now comes with the ability to dispatch events! Use it like:
this.dispatch('async:submitted', { detail: { quantity: 1 } })
Here's how it works. Start the normal way. In submit-confirm_controller.js, import the behavior - import { useDispatch } from 'stimulus-use' then create a connect() method with useDispatch(this) inside. This time, pass an extra option via the second argument: debug set to true.
| // ... lines 1 - 2 | |
| import { useDispatch } from 'stimulus-use'; | |
| // ... line 4 | |
| export default class extends Controller { | |
| // ... lines 6 - 13 | |
| connect() { | |
| useDispatch(this, { debug: true }); | |
| } | |
| // ... lines 17 - 51 | |
| } |
I'm adding this debug option temporarily. All stimulus-use behaviors support this option. When it's enabled, most log extra debug info to the console, which is handy for debugging. We'll see that in a minute.
Head down to submitForm(). Here's the plan: if the form submits via Ajax, let's wait for it to finish and then dispatch a custom event. Do that by adding const response = await... and then we need to make the method async.
| // ... lines 1 - 4 | |
| export default class extends Controller { | |
| // ... lines 6 - 35 | |
| async submitForm() { | |
| // ... lines 37 - 42 | |
| const response = await fetch(this.element.action, { | |
| // ... lines 44 - 45 | |
| }); | |
| // ... lines 47 - 50 | |
| } | |
| } |
To dispatch the event, the useDispatch behavior gives us a handy new dispatch() method. So we can say this.dispatch() and then the name of our custom event, which can be anything. Let's call it async:submitted. You can also pass a second argument with any extra info that you want to attach to the event. I'll add response.
| // ... lines 1 - 35 | |
| async submitForm() { | |
| // ... lines 37 - 47 | |
| this.dispatch('async:submitted', { | |
| response, | |
| }) | |
| } | |
| // ... lines 52 - 53 |
We won't need that in our example... but thanks to this, the event object that's passed to any listeners will have a detail property with an extra response key on it set to the response object... which might be handy in other cases.
And... that's it! It's a bit technical, but thanks to the async on submitForm(), the submitForm() method still returns a Promise that resolves after this Ajax call finishes. That's important because we return that Promise in preConfirm()... which is what tells SweetAlert to stay open with a loading icon until that call finishes.
Anyways, let's try it! Spin over, refresh, remove an item and confirm. Yes! Check out the log! We just dispatched a normal DOM event from our form element called submit-confirm:async:submitted. By default, the useDispatch behavior prefixes the event name with the name of our controller, which is nice.
Next: let's listen to this in our other controller and use it to reload the cart HTML. As a bonus, we'll add a CSS transition to make things look really smooth.
17 Comments
Hello Team,
I basically have the same problem as jmsche (and Kevin-C - but I dont use stimulus-use). As especially jmsches question is not completely answered, I have to post the issue again.
my template:
collection_controller.js:
sortable_controller.js
It fails without any error and looks to me, that the event is not firing. I did the monitorEvents($0) and a ton of other structures of the template. Just can't get it to work. Please help! :/
Hey Eric,
Yeah, communicating between 2 components might be tricky in Stimulus. Because DOM events always goes the next direction: from child elements to parents. In your exacmple you try to fire an event from the parent element so it won't hit the child one But not sure it will work either.. IIRC you fire that in a different direction and it just won't work in native Stimulus way.
But you can always dispatch real JS events like this, and it should always work:
and
This way it does not rely on Stimulus/DOM events and should work well.
Cheers!
Hey Victor,
thanks for your reply. I came up with my structure because I read that
this.dispatch()"releases" the Event from the element where the controller is attached and can be caught at that element or any parent. That's why I putstimulus_action('sortable', 'receivingEvent', 'collection:added')right there. With this structure I triedstimulus_action('...', '...', 'collection:added@window')aswell to catch the event on the "top level", but failed.It dawned on me, that my action is somewhat not working at all and I discovered, that it's not possible to use actions outside the controller scope they belong to. I'm not sure if it is never explicitly mentioned in the videos or if I just missed this part before, but that was my fundamental misconception.
I rearranged my code:
And now everything works as expected.
One last question though: Is there any reason why it would be better (if it's possible) to avoid listening to events on the window-element?
Hey Eric,
Great, glad it works for you! And thanks for sharing the updated working structure :)
Yes, there are some reasons why you may want to avoid global events.
I hope this helps!
Cheers!
Hey @Quentin-D
Have you tried what the docs say? It seems to me that they made part of Stimulus the
useDispatch()behaviorHello Ryan and team,
Having a tough time getting events to dispatch in stimulus. My assumption is that I can simply call
this.dispatch("event:tag"), this I get for free withimport { Controller } from '@hotwired/stimulus';I need not import anything else, is this correct?In my
checkoutcontroller the method that is called upon click viadata-action="click->checkout#choseOne"of<button>is:in the surrounding
<div>of the<button>I havedata-action="checkout:async:chose->plan-detail#displayChoice".This should mean when the event is dispatched, method:
displayChoice(event)is called on theplan-detailcontroller, correct?Well, that's not happening. I used the
monitorEvents($0)trick and saw no event fired. I also do not see the console.log output where I'd expect when thedisplayChoice(event)is called.Thanks for any tips, tricks, and teachings,
Kevin
Hey Kevin,
You only forgot to initialize the "dispatch" functionality. You can do it like this:
Cheers!
Hey MolloKhan,
Thank you, thought I might be missing something like that, so we still have to useDispatch in connect(), but you can call this.dispatch(). Ok, got it. Now I can see the event firing, but the data-action that's doing the listening doesn't seem to be catching the event because that controller method isn't executing. Is there reason you can think of why this wouldn't work in a modal?
Thanks,
Kevin
Hey Kevin,
Can you double-check that you're listening to the right event name? Stimulus prefix the event with the controller's name by default. https://github.com/stimulus-use/stimulus-use/blob/main/docs/use-dispatch.md#reference
Cheers!
useDispatchfromstimulus-useis deprecated, so I use native Stimulusthis.dispatch()function instead.However, the event does not appear in the console. Any way to enable debugging for the native
dispatch()function?Hey @jmsche!
I love that
dispatch()is now just part of Stimulus. About debugging - good question. I haven't usedthis.dispatch()yet... I would hope that the events might show in the console via Stimulus's debugging mode, but it sounds like that's not the case, at least not now.Fortunately, the events it dispatches are normal JS events. So you can use this trick: https://stackoverflow.com/questions/10213703/how-do-i-view-events-fired-on-an-element-in-chrome-devtools
In the console, type:
Then you'll see ALL events that are dispatched. It's a lot, but it should help.
Let me know if that's useful! Cheers!
It may be down to knowledge of js, but after removing "return" keyword and storing fetch Promise in "response" const, and the calling "this.dispatch", how this data are returned from "submitForm" method to "preConfirm" arrow method call?
Hey Peter L.
The main idea of this chapter is to get the response of the AJAX call and dispatch an event that something else (a listener) can handle later. We achieved that by calling
awaitright before the AJAX call. What it does is to resolve the promise and give you the result - it basically makes the process synchronous. After that, we just use thedispatchbehavior to dispatch a custom eventI hope it helps. Cheers!
Hello :)
You say "You can dispatch events without this behavior ... this just makes it easier.". But how could we do without this behavior? :)
Thanks.
Hi! Here's how you can dispatch a CustomEvent as seen in the MDN website, where obj is an HTML element:
In this case, the custom event is dispatches from obj and will bubble up to their parent elements!
"Houston: no signs of life"
Start the conversation!