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 SubscribeNow that we've created a colorId
value, we can pass that from the server into Stimulus and read it as this.colorIdValue
. Let's use that to select the color square on load.
Thanks to our organization, this will be no problemo. Replace the log with: if this.colorIdValue
: just in case we want to make this value optional. Inside, call this.setSelectedColor()
and pass this.colorIdValue
.
... lines 1 - 2 | |
export default class extends Controller { | |
... lines 4 - 10 | |
connect() { | |
... lines 12 - 13 | |
if (this.colorIdValue) { | |
this.setSelectedColor(this.colorIdValue); | |
} | |
} | |
... lines 18 - 48 | |
} |
I do love that we created that re-usable setSelectedColor()
method! Let's try this: fly over to the browser and... it... doesn't work? In the console, we have a giant error:
Cannot read property
classList
of undefined
... coming from setSelectedColor()
.
Let's go take a look. I think it's coming from right here. For some reason, the findSelectedColorSquare()
method is not finding the element... which is odd.
Scroll down to it. Ahh. The problem is the stronger type that the values API gave us. element.dataset.colorId
, which just uses the normal dataset
functionality, will be a String
. But this.selectedColorId
will now be a Number
... because if we scroll up, we set it to this.colorIdValue
, which we know is a true Number
type.
So our stronger type makes these not triple-equal each other. The easiest fix is to use double equals.
... lines 1 - 45 | |
findSelectedColorSquare() { | |
return this.colorSquareTargets.find((element) => element.dataset.colorId == this.selectedColorId); | |
} | |
... lines 49 - 50 |
In case you're wondering, at this time, there isn't anything like the values API for individual elements inside our controller.
Anyways, let's try it. Refresh and... got it! The color green is pre-selected! And if you temporarily unhide the select
element itself... yep! That updated too.
There's one last feature about the values API that we haven't talked about yet. And it's really going to help us. In fact, it's going to let us to delete a lot of code from our controller. It's called a change callback.
Very simply, we can tell Stimulus to automatically call a function whenever a value changes, like when our colorId
value changes. How? With a specially named method.
Add a new method called colorIdValueChanged()
. Inside, go steal the code from earlier: this.setSelectedColor(this.colorIdValue)
.
... lines 1 - 18 | |
colorIdValueChanged() { | |
this.setSelectedColor(this.colorIdValue); | |
} | |
... lines 22 - 50 |
And now we can remove the code inside connect()
.
Here's the flow: on load, the colorId
value will be read from our data attribute. That will change the colorId
value and cause our callback to be executed. The naming of the method is important: it must be exactly named like this for Stimulus to recognize it as a change callback.
Let's give it a go! Refresh and... yea, it did work! To make it more obvious, click red, then reload. Back to green!
Ready to have your mind blown? Find the data-controller
element in your inspector. 2 is the id of the green item. Let's change it to 1, which is red. Woh! The selected color square changed! Our callback is even executed when the value's data-attribute
is updated! That's bonkers.
Look back at our controller. Now, I'm wondering something: do we really need both a selectedColorId
property and a colorId
value? Don't they both... kinda store the currently selected color?
Yep! And the answer is that we do not need both.
A value is basically a property with superpowers. Values have the ability to read an initial value from a data attribute, support change callbacks and wear a bright red cape.
Check this out: in the colorIdValueChanged()
method, let's add all the logic that we need to get this to work on its own. In other words, I want to replace this.setSelectedColor()
with code that does the same thing.
Start by setting the value on the select: this.selectTarget.value = this.colorIdValue
.
... lines 1 - 18 | |
colorIdValueChanged() { | |
this.selectTarget.value = this.colorIdValue; | |
... lines 21 - 28 | |
} | |
... lines 30 - 58 |
The only other thing that we need to do inside here is a loop over the color squares to set the selected
class correctly. Do that with this.colorSquareTargets.forEach()
and pass this an arrow function with an element
argument. Inside, we can use an if statement to figure out if we should be adding the class or removing it: if element.dataset.colorId == this.colorIdValue
then we know this element is now the current color. Add the class with element.classList.add('selected')
. Else, remove the selected
class.
... lines 1 - 18 | |
colorIdValueChanged() { | |
... lines 20 - 21 | |
this.colorSquareTargets.forEach((element) => { | |
if (element.dataset.colorId == this.colorIdValue) { | |
element.classList.add('selected'); | |
} else { | |
element.classList.remove('selected'); | |
} | |
}); | |
} | |
... lines 30 - 58 |
Nice! Up in selectColor()
, we don't need to call setSelectedColor()
anymore. Instead, just set the value! Copy the event.currentTarget
code and say this.colorIdValue =
and paste.
... lines 1 - 12 | |
selectColor(event) { | |
this.colorIdValue = event.currentTarget.dataset.colorId; | |
} | |
... lines 16 - 29 |
That's it! When we click a color square, selectColor
will be called. Then we set this.colorIdValue
and that triggers our colorIdValueChanged()
method. Booya!
Test drive time! When we refresh... the initial color is selected. And when we click... that works too! We did lose the ability to click a second time to unselect a color - but we'll fix that in a minute.
Before we do, let's celebrate by removing a ton of code! We don't need setSelectedColor()
anymore... or findSelectedColorSquare()
... or the selectedColorId
property. I'll remove that in a minute.
If you want to get back the ability to click again to unselect a color, we can do that with a little extra logic in selectColor
. Add const clickedColor
equals the event.currentTarget
code.
For the next line, use the ternary syntax: this.colorIdValue =
, if clickedColor == this.colorIdValue
- so, if the clicked color is already selected - then set it to null
. Otherwise set it to clickedColor
.
... lines 1 - 12 | |
selectColor(event) { | |
const clickedColor = event.currentTarget.dataset.colorId; | |
this.colorIdValue = clickedColor == this.colorIdValue ? null : clickedColor; | |
} | |
... lines 17 - 30 |
Test it out: refresh... then click green again. Gone!
Go back to your editor. This is the final version of our controller. Oh, after I remove the unused selectedColorId
property... now this is the final version of our controller.
And look at it! It's less than 30 lines of code and is incredibly readable. This is how I want my JavaScript to look.
Head back to the homepage of our site. This has a functional search... but it's so boring. Next: let's add an Ajax-powered "quick search" that shows matching results under the search box as we type.
"Houston: no signs of life"
Start the conversation!
// 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
}
}