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.
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!
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.
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.
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?
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.
Hey @Quentin-D
Have you tried what the docs say? It seems to me that they made part of Stimulus the useDispatch()
behavior
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
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!
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!
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?
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!
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 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!
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:
// 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!
// 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
}
}
// 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
}
}