Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

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.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

When 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.

Leave a comment!

13
Login or Register to join the conversation
Default user avatar
Default user avatar unknown | posted 5 months ago | edited
Comment was deleted.

Hey @Quentin-D

Have you tried what the docs say? It seems to me that they made part of Stimulus the useDispatch() behavior

Reply
Kevin-C Avatar
Kevin-C Avatar Kevin-C | posted 7 months ago | edited

Hello 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 with import { Controller } from '@hotwired/stimulus'; I need not import anything else, is this correct?

In my checkout controller the method that is called upon click via data-action="click->checkout#choseOne" of <button> is:


choseOne(event) {
        console.log('chose = yes.');
        this.dispatch('async:chose');
    }

in the surrounding <div> of the <button> I have data-action="checkout:async:chose->plan-detail#displayChoice".
This should mean when the event is dispatched, method: displayChoice(event) is called on the plan-detail controller, correct?


displayChoice(event) {
        console.log('displayChoice');
    }

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 the displayChoice(event) is called.

Thanks for any tips, tricks, and teachings,
Kevin

Reply

Hey Kevin,

You only forgot to initialize the "dispatch" functionality. You can do it like this:

import { useDispatch } from 'stimulus-use';

export default class extends Controller {
    connect() {
        useDispatch(this);
    }
}

Cheers!

Reply
Kevin-C Avatar

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

Reply

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!

Reply
jmsche Avatar
jmsche Avatar jmsche | posted 8 months ago | edited

useDispatch from stimulus-use is deprecated, so I use native Stimulus this.dispatch() function instead.

However, the event does not appear in the console. Any way to enable debugging for the native dispatch() function?

Reply

Hey @jmsche!

I love that dispatch() is now just part of Stimulus. About debugging - good question. I haven't used this.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:

monitorEvents($0)

// to stop monitoring
//unmonitorEvents($0)

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!

Reply
Peter L. Avatar
Peter L. Avatar Peter L. | posted 1 year ago

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?

Reply

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 await right 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 the dispatch behavior to dispatch a custom event

I hope it helps. Cheers!

Reply
seb-jean Avatar
seb-jean Avatar seb-jean | posted 2 years ago

Hello :)
You say "You can dispatch events without this behavior ... this just makes it easier.". But how could we do without this behavior? :)
Thanks.

Reply

Hi! Here's how you can dispatch a CustomEvent as seen in the MDN website, where obj is an HTML element:


// add an appropriate event listener
obj.addEventListener("cat", function(e) { process(e.detail) });


// create and dispatch the event
var event = new CustomEvent("cat", {
    detail: {
        hazcheeseburger: true
    }
});


obj.dispatchEvent(event);

In this case, the custom event is dispatches from obj and will bubble up to their parent elements!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial works perfectly with Stimulus 3!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=8.1",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "composer/package-versions-deprecated": "1.11.99.1", // 1.11.99.1
        "doctrine/annotations": "^1.0", // 1.11.1
        "doctrine/doctrine-bundle": "^2.2", // 2.2.3
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.0.2
        "doctrine/orm": "^2.8", // 2.8.1
        "phpdocumentor/reflection-docblock": "^5.2", // 5.2.2
        "sensio/framework-extra-bundle": "^5.6", // v5.6.1
        "symfony/asset": "5.2.*", // v5.2.3
        "symfony/console": "5.2.*", // v5.2.3
        "symfony/dotenv": "5.2.*", // v5.2.3
        "symfony/flex": "^1.3.1", // v1.18.5
        "symfony/form": "5.2.*", // v5.2.3
        "symfony/framework-bundle": "5.2.*", // v5.2.3
        "symfony/property-access": "5.2.*", // v5.2.3
        "symfony/property-info": "5.2.*", // v5.2.3
        "symfony/proxy-manager-bridge": "5.2.*", // v5.2.3
        "symfony/security-bundle": "5.2.*", // v5.2.3
        "symfony/serializer": "5.2.*", // v5.2.3
        "symfony/twig-bundle": "5.2.*", // v5.2.3
        "symfony/ux-chartjs": "^1.1", // v1.2.0
        "symfony/validator": "5.2.*", // v5.2.3
        "symfony/webpack-encore-bundle": "^1.9", // v1.11.1
        "symfony/yaml": "5.2.*", // v5.2.3
        "twig/extra-bundle": "^2.12|^3.0", // v3.2.1
        "twig/intl-extra": "^3.2", // v3.2.1
        "twig/twig": "^2.12|^3.0" // v3.2.1
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.0
        "symfony/debug-bundle": "^5.2", // v5.2.3
        "symfony/maker-bundle": "^1.27", // v1.30.0
        "symfony/monolog-bundle": "^3.0", // v3.6.0
        "symfony/stopwatch": "^5.2", // v5.2.3
        "symfony/var-dumper": "^5.2", // v5.2.3
        "symfony/web-profiler-bundle": "^5.2" // v5.2.3
    }
}

What JavaScript libraries does this tutorial use?

// package.json
{
    "devDependencies": {
        "@babel/preset-react": "^7.0.0", // 7.12.13
        "@popperjs/core": "^2.9.1", // 2.9.1
        "@symfony/stimulus-bridge": "^2.0.0", // 2.1.0
        "@symfony/ux-chartjs": "file:vendor/symfony/ux-chartjs/Resources/assets", // 1.1.0
        "@symfony/webpack-encore": "^1.0.0", // 1.0.4
        "bootstrap": "^5.0.0-beta2", // 5.0.0-beta2
        "core-js": "^3.0.0", // 3.8.3
        "jquery": "^3.6.0", // 3.6.0
        "react": "^17.0.1", // 17.0.1
        "react-dom": "^17.0.1", // 17.0.1
        "regenerator-runtime": "^0.13.2", // 0.13.7
        "stimulus": "^2.0.0", // 2.0.0
        "stimulus-autocomplete": "^2.0.1-phylor-6095f2a9", // 2.0.1-phylor-6095f2a9
        "stimulus-use": "^0.24.0-1", // 0.24.0-1
        "sweetalert2": "^10.13.0", // 10.14.0
        "webpack-bundle-analyzer": "^4.4.0", // 4.4.0
        "webpack-notifier": "^1.6.0" // 1.13.0
    }
}
userVoice