Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

State in your 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

If we click a color multiple times, nothing happens. I want this to unselect that color. To accomplish this, we don't need to do anything special. On click, we could look at the currentTarget to see if it already has the selected class.

If you think about it, we're sort of storing "state" information - which color is currently selected - inside HTML elements. Specifically, if we want to know what the currently-selected color is, we need to check to see which color square has the selected class.

That's okay... but people! Stimulus gives us an object! And that means we can store info on it! We did this earlier with the "current count" on our counter controller.

Adding a new State Property

So on click, let's start storing which color id is currently selected. At the top of the class, invent a new property: how about selectedColorId = null.

... lines 1 - 2
export default class extends Controller {
selectedColorId = null;
... lines 5 - 22
}

Then, down in selectColor(), create a variable - const clickedColorId = set to the event.currentTarget code. Right below this, set the state property: this.selectedColorId = clickedColorId.

... lines 1 - 11
selectColor(event) {
const newColorId = event.currentTarget.dataset.colorId;
this.selectedColorId = newColorId;
... lines 15 - 21
}
... lines 23 - 24

We don't really need a variable yet, but it will make life a bit easier in a minute. Down at the bottom, instead of referencing the event code, just use this.selectedColorId.

... lines 1 - 11
selectColor(event) {
... lines 13 - 20
this.selectTarget.value = this.selectedColorId;
}
... lines 23 - 24

This by itself.... doesn't really do anything to help us. But we can now more easily use the property to figure out if the color that's being clicked is already selected.

Add an if statement near the top: if clickedColorId === this.selectedColorId, then we know that we're clicking on a color box that is already selected.

For this situation, copy the classList code from below, and make it event.currentTarget.classList.remove('selected'). Also set this.selectedColorId = null and this.selectTarget.value = '', or null would be fine. Then return.

... lines 1 - 11
selectColor(event) {
... lines 13 - 14
if (newColorId === this.selectedColorId) {
event.currentTarget.classList.remove('selected');
this.selectedColorId = null;
this.selectTarget.value = '';
return;
}
... lines 23 - 31
}
... lines 33 - 34

So when we click the already-selected color, we go here. Else we do the normal logic.

Let's try it! Refresh and let's inspect element, find the select and temporarily take off the d-none so we can see it.

Now, if we click red, it works! Click green, it works. Click green again... yes! It loses the border and the select element changes.

Reusable Controller Methods

Before we keep going, I want to reorganize things just a bit in our controller. End the selectColor method early and move most of the logic into a new setSelectedColor() method with a clickedColorId argument.

Then, call this from above: this.setSelectedColor()... and steal the event.currentTarget code. We don't need a variable anymore.

... lines 1 - 2
export default class extends Controller {
... lines 4 - 11
selectColor(event) {
this.setSelectedColor(event.currentTarget.dataset.colorId)
}
setSelectedColor(newColorId) {
... lines 17 - 33
}
... lines 35 - 41
}

This isn't going to quite work yet, but I want to explain why we're doing this. This is optional, but I like to have as many re-usable methods in my controller as possible. The nice thing about setSelectedColor() is that it's not dependent on the event: before we were reading event.currentTarget.

Now, anyone can call this method from anywhere, pass a color id and... everything will just work! Well, it's going to work... once we finish refactoring.

We can't use event.currentTarget anymore. But this is actually kind of cool! What we really need to find here is the currently-selected color box... since we're inside an if statement where we've determined that the user is tying to select a color that is already selected and we need to remove its selected class.

Now, thanks to the selectedColorId property, we can find the "currently selected color square" really easily! Let's add a helper method to do this: findSelectedColorSquare()

... lines 1 - 38
findSelectedColorSquare() {
... line 40
}
... lines 42 - 43

Inside return this.colorSquareTargets.find(). What we're going to do is loop over all the color square targets and return the one whose data-color-id attribute matches this.selectedColorId.

Pass find() a function with an element argument. I'm going to use the super fancy single line syntax to return element.dataset.colorId === this.selectedColorId.

... lines 1 - 38
findSelectedColorSquare() {
return this.colorSquareTargets.find((element) => element.dataset.colorId === this.selectedColorId);
}
... lines 42 - 43

So this method will either return the Element if one is selected or null. I'll add some docs above this to advertise that.

... lines 1 - 35
/**
* @return {Element|null}
*/
findSelectedColorSquare() {
... lines 40 - 43

Let's go use the new method: this.findSelectedColorSquare().classList.remove('selected'). And... we have one more spot down here: where we add that class. Since we've already set the new selectedColorId property, this will find the new element: this.findSelectedColorSquare().classList.add('selected').

... lines 1 - 15
setSelectedColor(newColorId) {
if (newColorId === this.selectedColorId) {
this.findSelectedColorSquare().classList.remove('selected');
... lines 19 - 23
}
... lines 25 - 31
this.findSelectedColorSquare().classList.add('selected');
... line 33
}
... lines 35 - 43

This shows off one of the nice things about storing state like selectedColorId: we can create useful methods - like findSelectedColorSquare() - and call them whenever we want.

Let's make sure I didn't break anything. Refresh, click red and click it again. All good!

Next: there's one big feature of Stimulus that we haven't talked about and it's actually brand new to Stimulus! It's the values API.

Leave a comment!

2
Login or Register to join the conversation
Dave V. Avatar
Dave V. Avatar Dave V. | posted 1 year ago

I found at least in chrome that setting the value of selectTarget to null doesn't reset the selector to show its placeholder, but empty string '' as you had done does.

Reply

Ha! That's interesting, thanks for the tip :)

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