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 SubscribeWe have a little itty bitty problem. When we click off of our search area, the suggestion box... just sticks there. We need that to close. How can we do that?
We could do it manually. We would register a click listener on the entire page document
and then detect if a click was inside our search area or outside of it.
But... I have a way better solution. Search for "stimulus use" to find a GitHub page. stimulus-use
is a library that's packed full of behaviors for Stimulus controllers. It's awesome.
I'll click down here to get into the documentation.
Here's a great example of the power of this library. Suppose you want to do something whenever an element appears in the viewport - like as the user is scrolling - or disappears from the viewport. You can easily do that with one of the behaviors called useIntersection
.
Basically, you activate it, give it some options if you want... and boom! The library will automatically call an appear()
method on your controller when your element enters the viewport and disappear()
when it leaves the viewport. How cool is that?
One of the other behaviors - useClickOutside
- is exactly what we need.
So let's get this installed. Over on "Usage"... actually "Getting Started", the name of the library is stimulus-use
. Spin over to your terminal and install it:
yarn add stimulus-use --dev
Again, the --dev
part isn't really important - that's just how I like to install things.
While that's working, let's go look at the documentation for useClickOutside
. I'll scroll down to "usage".
Ok: step 1 is to activate the behavior in our connect()
method. Cool. Copy this line... and let's make sure the library finished downloading. It did.
Over in the controller, go to the top to import the behavior: import {}
and then the behavior we need - useClickOutside
.
Sweet! PhpStorm auto-completed the rest of that line for me.
Below, add a connect()
method and paste: useClickOutside(this)
.
... line 1 | |
import { useClickOutside } from 'stimulus-use'; | |
... line 3 | |
export default class extends Controller { | |
... lines 5 - 10 | |
connect() { | |
useClickOutside(this); | |
} | |
... lines 14 - 27 | |
} |
For step 2, look at the docs: we need to add a clickOutside()
method. Ok! Let's add it at the bottom: clickOutside(event)
. When the user clicks outside of our controller element, we will set this.resultTarget.innerHTML = ''
.
... lines 1 - 3 | |
export default class extends Controller { | |
... lines 5 - 24 | |
clickOutside(event) { | |
this.resultTarget.innerHTML = ''; | |
} | |
} |
Done. Let's test it! Head back to the browser and refresh. Type a little to get some suggestions, then click off. Beautiful! And if I type again... it's back, then click off... and gone again.
People: that was like four lines of code!
Since that was so fast, let's do something else.
If I type really, really fast - watch the Ajax counter right here - yup! We make an Ajax call for every single letter no matter how fast we type. That's overkill. The fix for this is to wait for the user to pause for a moment - maybe for 200 milliseconds - before making an Ajax call. That's called debouncing. And there's a behavior for that: useDebounce
.
Let's try it! Scroll up to the example. Of course, we need to start by importing it. Oh, and this ApplicationController
thing? Don't worry about that: that's another, optional feature of this library, they're just mixing examples.
Over in the controller, at the top, import useDebounce
. Next... if you look at the other example, we activate it the same way. So, in connect()
, useDebounce(this)
. I'll add semi-colons... but they're obviously not needed.
... line 1 | |
import { useClickOutside, useDebounce } from 'stimulus-use'; | |
... line 3 | |
export default class extends Controller { | |
... lines 5 - 11 | |
connect() { | |
... line 13 | |
useDebounce(this); | |
} | |
... lines 16 - 29 | |
} |
Here's how this behavior works: we add a static debounces
property set to an array of methods that should not be called until a slight pause. That pause is 200 milliseconds by default.
For us, we want to debounce the onSearchInput
method. Copy the name then head up to the top of the controller: static debounces = []
with onSearchInput
inside.
... lines 1 - 3 | |
export default class extends Controller { | |
... lines 5 - 9 | |
static debounces = ['onSearchInput']; | |
... lines 11 - 29 | |
} |
Let's try it! Back to the browser, refresh and... type real fast! Ah! It exploded! This is due to a limitation of this feature. Because our browser is calling onSearchInput
, the behavior can't hook into it properly. Debouncing only works for methods that we call ourselves.
But that's no problem! We just need to organize things a bit better. Try this: close up onSearchInput
early and move most of the logic into a new method called async search()
with a query
argument.
Again, we're making this async
because we have an await
inside.
For onSearchInput
, we don't need the async
anymore... and we can now call this.search()
and pass it event.currentTarget.value
.
Tip
Starting with stimulus-use
0.51.2, the debounce library contains a bug.
See the comment from cctaborin
that describes a nice workaround until it's
fixed: https://bit.ly/use-debounce-workaround
... lines 1 - 3 | |
export default class extends Controller { | |
... lines 5 - 16 | |
onSearchInput(event) { | |
this.search(event.currentTarget.value); | |
} | |
... line 20 | |
async search(query) { | |
... lines 22 - 28 | |
} | |
... lines 30 - 33 | |
} |
Below, set the q
value to query
.
... lines 1 - 20 | |
async search(query) { | |
const params = new URLSearchParams({ | |
q: query, | |
... line 24 | |
}); | |
... lines 26 - 28 | |
} | |
... lines 30 - 35 |
This is good: we've refactored our code to have a nice, reusable search()
method. And now we can change the debounce
from onSearchInput
to search
.
... lines 1 - 3 | |
export default class extends Controller { | |
... lines 5 - 9 | |
static debounces = ['search']; | |
... lines 11 - 33 | |
} |
Testing time! Refresh and... type real fast. Yes! Only one Ajax call.
Alright! This feature is done! Next, on the checkout page, let's add a confirmation modal when the user removes an item from the cart. For this, we'll leverage a great third party library from inside our controller: SweetAlert.
Hey Kirill,
Thank you for sharing a link to BC notes in V3 of Stimulus, it might be useful for others.
Cheers!
using the beta version hepled, but it is not possible to use useDebounce, because of this bug:
https://github.com/stimulus...
any workarounds for that?
Thanks
Ok, I just had to think about it for a second myself. The workaround is easy and quite obvious.
Instead of:
const params = new URLSearchParams({
speaker: event.currentTarget.value,
preview: 1,
});
Is just use:
const params = new URLSearchParams({
speaker: this.inputTarget.value,
preview: 1,
});
and add the data-search-preview-target="result" attribute to the input-field and add input to the static targets so there is no need to use currentTarget anymore.
Hey Tristano,
So you basically need to replace "event.currentTarget" with "this.inputTarget". Thank you for sharing this solution with others!
Cheers!
Hi.
I've got 15 errors like:
Module not found:
"./node_modules/stimulus-use/dist/use-application/application-controller.js" contains a reference to the file "stimulus"
As I understand it's happens because I use "@hotwired/stimulus": "^3.0.0",
And when I install stimulus-use I see
warning " > stimulus-use@0.41.0" has unmet peer dependency "stimulus@>=1.1.1 <3"
How to fix it? Should I downgrade to "stimulus": "^2.0.0" ?
Thank you.
Hey Ruslan!
Ah, sorry about the troubles! The stimulus-use library has support for Stimulus v3, however they haven't created a tag yet, and I'm not sure why :/. Try running yarn add stimulus-use@beta --dev
to get their beta version, which should support it.
Cheers!
I had the same problem. But I kind of "complicated" my setting using yarn workspaces (multiple package.json's and multiple webpack configs, etc). But I narrowed it down to stimulus-use (for useDispatch).
Ryan's solution worked for me:
`"stimulus-use": "^0.50.0-2",`
Hi ,
Thank you. It helps. After yarn add stimulus-use@beta --dev
We can see "stimulus-use": "^0.50.0-2" in package.json
Hello!
Great tutorial as always.
I've implemented the debounce feature of stimulus-use, however after the wait I'm consistently getting the following JS error when the "search" tries to execute:
Uncaught TypeError: Cannot create property 'params' on string 'mystring'
at vendors-node_modules_symfony_stimulus-bridge_dist_index_js-node_modules_core-js_modules_es_nu-56f62f.js:3560:54
at Array.forEach (<anonymous>)
at callback (vendors-node_modules_symfony_stimulus-bridge_dist_index_js-node_modules_core-js_modules_es_nu-56f62f.js:3560:18)
Where 'mystring' is what I'm typing in the input field.
I've the following installed: Got the same error with stimulus-use 0.51.3 so upgraded to 0.52.0 and still the same issue.
yarn list |grep stimulus
├─ @hotwired/stimulus-webpack-helpers@1.0.1
├─ @hotwired/stimulus@3.2.1
├─ @symfony/stimulus-bridge@3.2.1
│ ├─ @hotwired/stimulus-webpack-helpers@^1.0.1
├─ stimulus-autocomplete@3.0.2
├─ stimulus-use@0.52.0
Here is my implementation code:
import { useDebounce } from 'stimulus-use';
....
connect() {
useDebounce(this, {wait: 500});
}
onSearchInput(event) {
this.contentTarget.style.opacity = .5;
this.search(event.currentTarget.value);
this.contentTarget.style.opacity = 1;
}
async search(query) {
const qs = new URLSearchParams({
plan: this.selectedOptions.plan,
order: this.selectedOptions.order,
q: query,
stars: this.selectedOptions.stars,
ajax: 1
});
const response = await fetch(`${this.reviewsUrlValue}?${qs.toString()}`);
this.contentTarget.innerHTML = await response.text();
}
Reviewing the context of the debounce error, we find ourselves here in the code:
class DebounceController extends _hotwired_stimulus__WEBPACK_IMPORTED_MODULE_0__.Controller {
}
DebounceController.debounces = [];
const defaultWait$1 = 200;
const debounce = (fn,wait=defaultWait$1)=>{
let timeoutId = null;
return function() {
const args = Array.from(arguments);
const context = this;
const params = args.map(arg=>arg.params);
const callback = ()=>{
args.forEach((arg,index)=>(arg.params = params[index]));
return fn.apply(context, args);
}
;
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(callback, wait);
}
;
}
;
Specifically this line:
args.forEach((arg,index)=>(arg.params = params[index]));
Would love any thoughts about things to try.
Thanks,
Kevin
Hello Kevin,
I had the same problem.
I fixed it by removing the parameter from my function search().
I use a target on my input tag to catch my parameter.
<input
name="q"
value="{{ searchTerm }}"
placeholder="Search products..."
type="search"
class="form-control"
data-action="search-preview#onSearchInput"
data-search-preview-target="input" {# Add a target #}
>
So I can use my search function without parameter :
import { Controller } from '@hotwired/stimulus';
import { useClickOutside, useDebounce } from 'stimulus-use';
export default class extends Controller {
static values = {
url: String,
}
static targets = ['result', 'input']; // Add the target
static debounces = ['search'];
connect() {
useClickOutside(this);
useDebounce(this);
}
onSearchInput(event) {
this.search();
}
async search() { // Remove parameter
const params = new URLSearchParams({
q: this.inputTarget.value, // Use the target vlaue
preview: 1,
});
const response = await fetch(`${this.urlValue}?${params.toString()}`);
this.resultTarget.innerHTML = await response.text();
}
clickOutside(event) {
this.resultTarget.innerHTML = '';
}
}
I hope this helps!
Hi,
I faced the same problem and made it work by passing the value inside an array, I hope it helps:
onSearchInput(event) {
this.search([event.currentTarget.value]);
}
Hey everyone!
This problem is due to a "fix" introduced in stimulus-use 0.51.2! See the answer from @Giuseppe-P for details - https://symfonycasts.com/screencast/stimulus/stimulus-use#comment-29400
And I'll add a note :)
UPDATE: Hmm, I'm having some DIFFERENT trouble now with that solution. I'd love to know if it works for someone else - I think this is a bug on stimulus-use and I'm working on a reproducer. It seems you need to call this.search({params: event.currentTarget.value})<br />
AND then in search()
, change to query.params
... which all seems a bit nuts :)
Cheers!
Hey @weaverryan I got some issues too..
Here my search-preview_controller:
import { Controller } from "@hotwired/stimulus";
import { useClickOutside, useDebounce } from "stimulus-use";
export default class extends Controller {
static values = {
url: String,
}
static targets = ['result'];
static debounces = ['search'];
connect() {
useClickOutside(this);
useDebounce(this);
}
onSearchInput(event) {
this.search({params: event.currentTarget.value})
}
async search(query) {
const params = new URLSearchParams({
q: query,
preview: 1,
});
const response = await fetch(`${this.urlValue}?${params.toString()}`);
this.resultTarget.innerHTML = await response.text();
}
clickOutside(event) {
this.resultTarget.innerHTML = '';
}
}
So I don't have the issue mentionned about the params, but... No results.
Before using debounce, the result was fine.
Any idea ? Do you think it's linked ?
Thanks!
Hey @Christina-V!
After adding that comment, I looked deeper and it seems like the solution may NOT be so simple. I think the latest version of useDebounce is plain broken. I added a comment about it on the issue - https://github.com/stimulus-use/stimulus-use/issues/295#issuecomment-1520768400 - if we're able to get your example working - you could comment on there to help push that along :).
So, you are correctly (according to the current way that useDebounce()
works) are passing {params: event.currentTarget.value}
to this.search()
. The 2nd part of the equation, I believe, is that your search()
method needs to accept this whole object. So:
async search(searchOptions) {
const params = new URLSearchParams({
q: searchOptions.params,
preview: 1,
});
// ...
}
That's horribly ugly and wrong... but I believe that's what's needed. In summary, here is how it seems to currently work:
A) When you call a debounced method, you must pass an object with a params
key on it
B) Then, that entire object is passed to your method... so you need to read the .params
key back out to get the original value.
Again, if my workaround works for you, I'd love if you could comment on the issue about what work-around you needed to add.
Thanks!
@weaverryan,
I can confirm the situation you describe !
I was eager to use my debounced onSearchInput
method after the tutorial update, but it turns out that the search feature in my app is "broken" like @Christina-V 's one : no error in the console but an empty result from my Symfony app...
When I step-debugged in my Symfony Controller, what is send to PHP (if you keep the code as-is in the search
function) is the string "[object Object]" : good luck to find anything with this search term 😅
So yeah, like you, I receive the whole params
object in my search
function and I had to change my function to what you shared...
I may switch to the cctaborin's solution that is based on the Values API...
Thanks all for the debug inquiry (and Ryan for this in-depth tutorial AND follow-up)
Thank you for confirming! I'm now convinced we've understood and described the situation correctly. Hopefully we'll get some movement on the issue - https://github.com/stimulus-use/stimulus-use/issues/295
And yes, the solution from cctaborin's looks solid - https://symfonycasts.com/screencast/stimulus/stimulus-use#comment-29193
Cheers!
Hey Kevin,
Seems you messed up in that DebounceController
implementation. I'd recommend you to dump the data you're working with there using console.log()
feature and see the data in the Chrome Console. For example, add console.log(params);
to make sure the value of that variable is exactly what you're supposed to handle in that spot. And so on, dump more vars to make sure you're working with correct data there. When you will see what data you're working with - I think it will give you some glue... otherwise, it's difficult to say.
I hope this helps!
Cheers!
Hey Vic!
Appreciate your response. The DebounceController is not mine, that's right out of the stimulus-use library, I was just posting it for reference. And, you're probably right, something is goofy there. :)
Hey Kevin,
Oh, I got it ) Hm, then it should be OK... probably you messed up with its configuration? Did you check official docs?
Cheers!
I've checked the source code: from v0.50 it seems the debounce wants an object with a params property:
use-debounce.ts
const args = Array.from(arguments)
const params = args.map(arg => arg.params)
So I fixed with:
onSearchInput(event) {
this.search({params: event.currentTarget.value})
}
v0.41 arguments was taken, so in the video passing event.currentTarget.value directly seems ok.
const args = arguments
Ah, righteous! Thank you @Giuseppe-P!
This was changed in 0.51.2, of all random versions :p. Here is the PR https://github.com/stimulus-use/stimulus-use/pull/252
I'll add a note!
Hey Giuseppe,
Thank you for sharing this tip! Seems some BC breaks were introduced in a newer version, we will add a note.
Cheers!
His everyone, I have encountered problems with stimulus-use. Webpack reports this error to me:
Module build failed: Module not found:
"./node_modules/stimulus-use/dist/use-hotkeys/use-hotkeys.js" contains a reference to the file "hotkeys-js".
This file can not be found, please check it for typos or update it if the file got moved.
If anyone could help me please I searched the web. But I did not find any solution.
Thanks
I just found the solution, it requires installing hotkeys.js. Hope this helps those who are going to encounter this problem. Thanks for your great tutorials !!!!
When i try to import useClickOutside from stimulus-use, i get the following error from in webpack watch:
Module build failed: Module not found:<br />"../node_modules/stimulus-use/dist/use-application/application-controller.js" contains a reference to the file "stimulus".<br />This file can not be found, please check it for typos or update it if the file got moved.
stimulus_controller.js:
import { Controller } from 'stimulus';<br />import { useClickOutside } from 'stimulus-use'import
Any Ideas? (Im not using the Tutorial files. I try to use stimulus in my own app)
Never mind. I got it. Because i use ddev i installed stimulus-use in the wrong directories.
Thanks for your great tutorials!
// 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
}
}
Personally when I am typing something to search box I really do not like mouse-only behaviour.
Was looking how to add 'ESC' key behaviour to this modal and also key-down/enter one.
ESC key listening:
https://discuss.hotwired.dev/t/add-and-remove-eventlisteners/710/2
If link is not working I am writing down kaspermeyer's answer:
<blockquote>Why not make it a Stimulus action instead, though? If you define your modal somewhere along these lines:</blockquote>
You could simplify your controller and you won’t have to manage event listeners manually: