Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Stimulus Behaviors: stimulus-use

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

We 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.

Hello stimulus-use

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.

Installing & Activating useClickOutside

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!

Debouncing with useDebounce

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.

Leave a comment!

33
Login or Register to join the conversation
Peter L. Avatar
Peter L. Avatar Peter L. | posted 1 year ago | edited

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>


<div data-controller="modal" data-action="keydown@window->modal#close">
    <!-- ... -->
</div>

You could simplify your controller and you won’t have to manage event listeners manually:

import { Controller } from 'stimulus'

export default class extends Controller {
  static targets = [ "wrapper" ]

  close(event) {
    if (event.keyCode == 27) {
      this.wrapperTarget.style.display = 'none'
    }
  }
1 Reply

hey Peter L.

That is pretty good example! Thx for sharing it!

Cheers!

Reply
Kirill Avatar

For stimulust v3 usage check BC notice -- https://github.com/stimulus...

1 Reply

Hey Kirill,

Thank you for sharing a link to BC notes in V3 of Stimulus, it might be useful for others.

Cheers!

1 Reply
TristanoMilano Avatar
TristanoMilano Avatar TristanoMilano | Victor | posted 1 year ago

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

1 Reply
TristanoMilano Avatar
TristanoMilano Avatar TristanoMilano | TristanoMilano | posted 1 year ago | edited

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.

1 Reply

Hey Tristano,

So you basically need to replace "event.currentTarget" with "this.inputTarget". Thank you for sharing this solution with others!

Cheers!

Reply
Ruslan Avatar

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.

1 Reply

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!

Reply
Default user avatar
Default user avatar Zoran | weaverryan | posted 1 year ago | edited

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",`
1 Reply
Ruslan Avatar

Hi ,
Thank you. It helps. After yarn add stimulus-use@beta --dev
We can see "stimulus-use": "^0.50.0-2" in package.json

Reply

Thanks! I am always thankful because you make everything look easy.

Reply
Kevin-C Avatar
Kevin-C Avatar Kevin-C | posted 6 months ago | edited

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

Reply

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!

4 Reply

This worked for me, thank you @cctaborin

Reply
SamuelVicent Avatar
SamuelVicent Avatar SamuelVicent | Kevin-C | posted 4 months ago

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]);
}
3 Reply

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!

1 Reply
Christina-V Avatar
Christina-V Avatar Christina-V | weaverryan | posted 3 months ago | edited

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!

Reply

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!

Reply
LexLustor Avatar
LexLustor Avatar LexLustor | weaverryan | posted 3 months ago | edited

@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)

Reply

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!

Reply
Ludwig Avatar

Hello Kevin,

I have the same problem. Have you already found a solution for this?

Thanks

Reply
Kevin-C Avatar

Hey Ludwig,
Never got to the bottom of it and had to move on.
Good luck.

-Kevin

Reply

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!

Reply
Kevin-C Avatar

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. :)

Reply

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!

Reply
Giuseppe-P Avatar
Giuseppe-P Avatar Giuseppe-P | Victor | posted 3 months ago | edited | HIGHLIGHTED

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
1 Reply

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!

1 Reply

Hey Giuseppe,

Thank you for sharing this tip! Seems some BC breaks were introduced in a newer version, we will add a note.

Cheers!

Reply
El hadji babacar S. Avatar
El hadji babacar S. Avatar El hadji babacar S. | posted 2 years ago

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

Reply
El hadji babacar S. Avatar
El hadji babacar S. Avatar El hadji babacar S. | El hadji babacar S. | posted 2 years ago

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 !!!!

1 Reply
Kai Avatar
Kai Avatar Kai | posted 2 years ago | edited

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)

Reply
Kai Avatar

Never mind. I got it. Because i use ddev i installed stimulus-use in the wrong directories.
Thanks for your great tutorials!

1 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