Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Listening to An Event From Another Controller

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

Thanks to the useDispatch behavior, after the delete form submits via Ajax and finishes, our submit-confirm controller dispatches a custom event called submit-confirm:async:submitted. Copy that event name: we'll need it in a few minutes.

This is awesome because we can listen to that event from our other controller so that we can run code whenever an item is removed. At the top, we can remove that debug flag now that we know the event is dispatching correctly.

... lines 1 - 4
export default class extends Controller {
... lines 6 - 13
connect() {
useDispatch(this);
}
... lines 17 - 51
}

Custom Events are just Normal Events

So: how can we listen to the new event from inside cart-list controller? Well, think about how a normal event works. If I click a link, for example, this checkout link, my browser first calls any click listeners attached to the thing I actually clicked. So, the <a> tag. Then, the event bubbles up. That's a fancy way of saying that the browser next calls any click listeners on the element above this: this <div>. Then... it calls any click listeners on the element above that... and then above that... and then above that... all the way until it gets to the body.

Whelp, our custom event is no different. When you call dispatch(), it dispatches the event on the element attached to the controller, though that's configurable.

For us, it means that the event is being dispatched on the form element. When that happens, our browser first checks to see if there are any listeners to our submit-confirm:async:submitted event on the form element. Then it bubbles up: checking each element up the tree to see if each has any listeners to our custom event.

This means two things for us. First, our custom event is no different than a click event. So to listen to it, we can use a Stimulus action. And second, we can attach that action to the form element or to any of its ancestors.

Adding the Custom Event Action

So... where should we add the action? Over in the template, find the div that's around the row for a single item: it's this cart-item element.

Add the action here. I'll pop things onto multiple lines... and then say data-action="", the name of our event - paste submit-confirm:async:submitted - an ->, the name of our controller - cart-list - a # sign, and finally the name of the method to call when that event happens. How about removeItem.

... lines 1 - 2
{% block body %}
... lines 4 - 30
{% for item in cart.items %}
<div
class="cart-item row p-3"
data-action="submit-confirm:async:submitted->cart-list#removeItem"
>
... lines 36 - 71
</div>
... lines 73 - 76
{% endfor %}
... lines 78 - 96
{% endblock %}
... lines 98 - 99

Why am I adding the action to this exact div? Well, it won't matter at first. But in a few minutes, it'll give us the ability to access this div and add extra logic to make it fade out.

Over in the controller, rename connect() to removeItem(), give it an event argument, and let's console.log() our very favorite event.currentTarget.

... lines 1 - 2
export default class extends Controller {
removeItem(event) {
console.log(event.currentTarget);
}
}

Ok team: let's find out if our controllers are communicating. Refresh, hit remove and confirm. Over in the console... yes! It hit our new log and the currentTarget is the div around the removed row.

An Ajax Endpoint for "Partial" HTML

What we really want to do in this method is make an Ajax call to an endpoint that will return the new HTML for the entire cart area. We can create that endpoint with some clever organization.

Start in the template. Copy all of the HTML that's inside of our cart area. So everything that's inside of the cart-list controller element: this div... all the way down to the end. Yep, that looks right.

Now create a new template in templates/cart/ called, how about _cartList.html.twig, and paste.

<div>
<div class="row p-3">
... lines 3 - 13
{% for item in cart.items %}
<div
class="cart-item row p-3"
data-action="submit-confirm:async:submitted->cart-list#removeItem"
>
... lines 19 - 54
</div>
... lines 56 - 59
{% endfor %}
... lines 61 - 66
</div>
... lines 68 - 76

Back in the original template, include that with {{ include('cart/_cartList.html.twig') }}.

... lines 1 - 2
{% block body %}
... lines 4 - 13
<div
class="component-light p-3"
{{ stimulus_controller('cart-list') }}
>
{{ include('cart/_cartList.html.twig') }}
</div>
... lines 20 - 22
{% endblock %}
... lines 24 - 25

This won't change anything yet: our page still works like it did before.

But now we can add a route & controller that returns just this template partial.

Open src/Controller/CartController.php. This is the controller that renders the shopping cart page. Right below that method, add another one: public function _shoppingCartList().

I'm keeping with the convention of prefixing my template partials - or even controllers that return a "fragment" of HTML - with an underscore. Above this, add @Route() and set the URL to be /cart - to match what's above - and then /_list. Name the route _app_cart_list.

... lines 1 - 18
class CartController extends AbstractController
{
... lines 21 - 37
/**
* @Route("/cart/_list", name="_app_cart_list")
*/
public function _shoppingCartList(CartStorage $cartStorage)
{
... lines 43 - 45
}
... lines 47 - 105
}

Beautiful! To render the new template, we need one variable: cart... which we get via this CartStorage service that's custom to our project. Copy that argument, paste it down here, and return $this->render('cart/_cartList.html.twig') passing a cart variable set to $cartStorage->getOrCreateCart().

... lines 1 - 40
public function _shoppingCartList(CartStorage $cartStorage)
{
return $this->render('cart/_cartList.html.twig', [
'cart' => $cartStorage->getOrCreateCart(),
]);
}
... lines 47 - 107

We're done! Go try it in the browser by going directly here. So /cart/_list and... got it! Hit back.

Next: let's update our controller to make an Ajax call to this endpoint and replace the entire cart area with fresh HTML. After we do that, will we need to, somehow, re-initialize our Stimulus controllers on the new HTML elements?

And, as a bonus, we'll add a basic CSS transition to really make things shine.

Leave a comment!

2
Login or Register to join the conversation
Alcides Avatar
Alcides Avatar Alcides | posted 11 months ago | edited

Hi Ryan, nice work!!

Just a note. When I have nested stimulus controllers and the child one inside a turbo frame like this:

<div data-action="controller_2">
	<turbo-frame id="frame_id">
		<div data-action='controller_1:event_name->controller_2#method_name'>
			<div data-controller="controller_1">
				<button data-action="controller_1#dispatch_event_method"></button>
			</div>
		</div>
	</turbo-frame>
</div>

The method_name in controller_2 is executed only when the frame is loaded for the first time, but if the same content is reloaded in the frame through Turbo frame mechanism, the event is no longer propagated correctly when the button is clicked. For it to work I have to put the div that captures the event as the parent of the frame like this:

<div data-action='controller_2'>
	<div data-action='controller_1:event_name->controller_2#method_name'>
		<turbo-frame id="frame_id">
			<div data-controller="controller_1">
				<button data-action="controller_1#dispatch_event_method"></button>
			</div>
		</turbo-frame>
	</div>
</div>

Or this way using @window:

<div data-action='controller_2'>
	<div data-action='controller_1:event_name@window->controller_2#method_name'></div>
	<turbo-frame id="frame_id">
		<div data-controller="controller_1">
			<button data-action="controller_1#dispatch_event_method"></button>
		</div>
	</turbo-frame>
</div>

Cheers.

Reply

Hey @Alcides!

Wow, that's super weird! It honestly smells like a bug - Stimulus is normally smart enough to not care if new content is loaded via Ajax later onto the page. So, I believe you - but this is very strange indeed. Good workaround though :).

Cheers!

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