Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Communicating between controllers #35

Closed
vojtad opened this issue Jan 4, 2018 · 25 comments
Closed

Communicating between controllers #35

vojtad opened this issue Jan 4, 2018 · 25 comments

Comments

@vojtad
Copy link

@vojtad vojtad commented Jan 4, 2018

I know this isn't exactly an issue, but I think it is a good question. I think of your controllers as controllers for components on a page.
How would you suggest to communicate between more controllers?

I have two components List (data rendered in a list) and ListFilter (bunch of selects and text inputs). List is loaded using AJAX. It should be reloaded every time ListFilter changes. I would like to use separate controllers for List and ListFilter to keep files small and organized.

So, how to trigger a List reload when ListFilter changes?

@georgeclaghorn
Copy link
Contributor

@georgeclaghorn georgeclaghorn commented Jan 4, 2018

From a Controller instance, you can call this.application.getControllerForElementAndIdentifier:

import { Controller } from "stimulus"

export default class ListFilterController extends Controller {
  // ...

  reloadList() {
    this.listController.reload()
  }

  get listController() {
    return this.application.getControllerForElementAndIdentifier(this.element, "list")
  }
}
@vojtad
Copy link
Author

@vojtad vojtad commented Jan 4, 2018

Thanks! I think this is exactly what I was looking for.

I suppose I should pass the element List component is on as first argument for getControllerForElementAndIdentifier, right?

So when a List is next sibling to a ListFilter in the DOM, I should call getControllerForElementAndIdentifier(this.element.nextElementSibling, "list") to get the ListController instance, right? Or would you suggest different way to find the List's element?

@georgeclaghorn
Copy link
Contributor

@georgeclaghorn georgeclaghorn commented Jan 4, 2018

I wouldn’t suggest relying on the relative orders of HTML elements.

Prefer targets to access particular elements. Can you move the data-controller="list-filter" annotation to a shared parent element and make the list element a target?

@vojtad
Copy link
Author

@vojtad vojtad commented Jan 4, 2018

Yeah, I can do that. This look like a better way to go, thanks.

If it wouldn't be possible the only way I can think of is to create a parent Controller with targets on both List and ListFilter. Then I would override the connect function of the parent Controller to pass ListController instance to ListFilterController instance.

However, I am not sure whether ListController and ListFilterController are already connected when the connect function for the parent Controller is called. Do you have any idea about this, please?

@domchristie
Copy link
Contributor

@domchristie domchristie commented Jan 4, 2018

FWIW T3 (another JS framework for the HTML you already have ;) has a simple messaging system for communicating between modules (or controllers), keeping them loosely coupled. I wonder if this is something that Stimulus might consider?

The T3 equivalent would look something like:

Box.Application.addModule('list-filter', function (context) {
  return {
    onchange: function () {
      context.broadcast('listfilterchanged', { filterParam1: value1, … })
    }
  }
})

Box.Application.addModule('list', function (context) {
  function reload (params) {
    // reload list with AJAX
  }
  
  return {
    onmessage: {
      listfilterchanged: reload
    }
  }
})

That way other controllers could respond to the listfilterchanged event

@sstephenson
Copy link
Contributor

@sstephenson sstephenson commented Jan 4, 2018

We don’t have anything built in for this right now. The easiest thing to do is probably to emit a DOM event from the inner controller and observe it with an action from the outer controller.

What I would like to see eventually is a data-outlet attribute that connects delegate properties on a child controller directly to a containing element’s controller. Something like Interface Builder outlets in Cocoa.

@sstephenson
Copy link
Contributor

@sstephenson sstephenson commented Jan 8, 2018

Closing this issue for now, but feel free to continue discussing it here.

@sstephenson sstephenson closed this Jan 8, 2018
@abulka
Copy link

@abulka abulka commented Jan 9, 2018

Could someone please explain exactly what the parameters to getContextForElementAndIdentifier are, what they mean and why I need both an element and a string as parameters? Why can't I find a controller simply by its string name e.g. "hello"?

If I have two controllers, 'hello' and 'out' and I want to get to the 'out' controller from inside the 'hello' controller. It seems that I need to pass both parameters, otherwise I get null. And only the combination of this.application.getControllerForElementAndIdentifier(this.element, "hello") work - which just gets me the controller I'm already in. What exact two parameters do I need to pass to get to the other controller named "out" - and why can't I just use the string name of that controller?

A bit of background: I too am trying to talk from one controller to another. My first controller wants to output some information in an abstract way, so wants to talk to another controller's write() method, which will do something cool with it. I'm hoping this is the sort of use case stimulus is good for?

@vojtad
Copy link
Author

@vojtad vojtad commented Jan 9, 2018

First parameter is element the controller is connected to and the second parameter is name of the controller you want to get instance of.

You need first parameter because controller of the same name can be connected to more elements on the page. So the function needs to know where to look for the controller instance you want to get.

And you need second parameter to specify controller's name because there can be more than one controller connected to one element.

So, one way to do this is to nest components and then handle this in the parent controller as @georgeclaghorn suggested. Second clean way I can think of without relying on access to particular elements would be to add a listener to document for custom event in one component and fire this event in the second one.

@incompletude
Copy link

@incompletude incompletude commented Nov 2, 2018

We don’t have anything built in for this right now. The easiest thing to do is probably to emit a DOM event from the inner controller and observe it with an action from the outer controller.

How should one do that?

@alinnert
Copy link

@alinnert alinnert commented Nov 15, 2018

We don’t have anything built in for this right now. The easiest thing to do is probably to emit a DOM event from the inner controller and observe it with an action from the outer controller.

How should one do that?

Here's an example: #200 (comment)

@kinnrot
Copy link

@kinnrot kinnrot commented Nov 25, 2019

I started to work on something for stimulus, check it out, https://github.com/kinnrot/telepub tell me what you think

@leastbad
Copy link

@leastbad leastbad commented Feb 17, 2020

I've been using a pattern for several months that allows me to access the controller instances attached to DOM elements without having to call getControllerForElementAndIdentifier, a method which isn't available outside of a Stimulus controller (as you need access to the application scope) - making it inaccessible from a jQuery plugin or the console window.

Just put this into your controller's connect() method:

this.element[this.identifier] = this

This means that if you attach a comments controller to a DOM element, all you need to do to access the internal scope of your controller is element.comments. This might not be what some folks mean by communication so emitting events is still your best bet for those scenarios.

I wrote about it at length in this post: https://leastbad.com/stimulus-power-move

I offer a slightly longer version that automatically camelCases the controller identifier, too.

@forelabs
Copy link

@forelabs forelabs commented Feb 26, 2020

@leastbad thanks for that, really helpful and feels good when using it.

We are using namespaced controllers so we have something like namespace--controller, your suggested camelize function did not worked for us here.
We have implemented a different one:

export function camelize(str: string) {
    return str.split(/[-_]/).map(w=> w.replace(/./, m=> m.toUpperCase())).join("").replace(/^\w/, c => c.toLowerCase());
}
@leastbad
Copy link

@leastbad leastbad commented Feb 26, 2020

Great catch, @forelabs. I'm always excited to see bullet-proof code broken. 😀

I'm going to borrow your code and update my post.

@javan
Copy link
Contributor

@javan javan commented Feb 27, 2020

Just put this into your controller's connect() method:

this.element[this.identifier] = this

If you do this, and I don't recommend that you do, be very careful with your controller names. If they conflict with any of the element's own property names (there are hundreds!) things will go 💥. For example:

// value_controller.js

export default class extends Controller {
  connect() {
    this.element[this.identifier] = this
  }
}
<input type="text" data-controller="value">

👇

@forelabs
Copy link

@forelabs forelabs commented Feb 27, 2020

To be on the safe side you could put stimulus in front or any other unique identifier. But good to hint that.

@leastbad
Copy link

@leastbad leastbad commented Feb 28, 2020

Hey @javan,

Your advice is solid. People should definitely careful to test whatever objects they attach to for potential namespace collisions. If you check out the original blog post on the technique, you'll see that I'm careful to explain to developers that they can use any valid string.

All of that said, I am a bit bummed that you seem to be throwing shade on a useful technique without offering a superior approach. Again, getControllerForElementAndIdentifier only works when you're already inside of another controller.

You can use DOM events for cross-controller communication, but that's not really a substitute for accessing the internal state of a controller.

@geetfun
Copy link

@geetfun geetfun commented Aug 21, 2020

A different take on this problem of inter-controller communication. Some sample code:

// dashboard_controller.js

import { Controller } from "stimulus"

export default class extends Controller {  
  connect() {
    this.name = "Simon"

    $(document).on('dashboard.state', function(event, callback) {
     callback(this)
    }.bind(this))
  } 
}

And in the controller that wants to grab information from the first controller:

// sidebar_controller.js

import { Controller } from "stimulus"

export default class extends Controller {  
  getDashboardState() {
    $(document).trigger('dashboard.state', function(dashboardState) {
  // Your code to handle the state
})
  }
}

Please excuse my use of jQuery, but wanted to share the concept of it (source).

@leastbad
Copy link

@leastbad leastbad commented Aug 21, 2020

Hey Simon, that's actually pretty interesting. Do you have a solution in mind for accessing multiple instances of a controller? One thing that I like about assigning the controller instance to an accessor on the DOM element (call it the sharp knives approach) it that it allows multiple instances of a controller in the DOM... and heck, multiple controllers on an element, potentially communicating through the one thing they all have in common.

jQuery isn't itself a bad thing, and it's a reality for many projects. I actually like that Stimulus provides a viable onramp for jQuery devs that suddenly find themselves in a world where they have a legacy codebase that isn't responding well to webpack and Turbolinks. The biggest strike against jQuery in this regard is that it has its own proprietary events implementation. I did what I could to make the problem easier by releasing jquery-events-to-dom-events.

@geekq
Copy link

@geekq geekq commented Sep 28, 2020

The nice approach suggested by @leastbad should be part of the the StimulusJS documentation! (possibly with all the mentioned notes regarding multi-word names, namespace, collision with existing properties. I personally use this.element.controller = this since I only have a single controller attached to that element).

It took me some days to get to this good solution. (first trying all the hacky and questionable solutions including getControllerForElementAndIdentifier)

@woto
Copy link

@woto woto commented Oct 17, 2020

Oh c'mon I don't know the reason why author still didn't mention https://github.com/stimulus-use/stimulus-use/blob/master/docs/application-controller.md but it works like a charm! :) (passing events from child to parent)

@toddkummer
Copy link

@toddkummer toddkummer commented Dec 28, 2020

Based on some of the previous suggestions, I went with the approach of using custom events to let child controllers register with the parent controller. I followed the pattern of static declarations (targets, classes, and values) and added declarations for parent and children.

Here's what the controllers might look like:

class ItemsController extends ApplicationController {
  static children = ['item']
}

class ItemController extends ApplicationController {
  static parent = 'items'
}

Like targets, this adds properties. In this example, the items controller would have itemChildren and itemChild while each item controller simply gets a parent property.

Here's a proof of concept with the full example. I'm also working on a fork, if there's any interest in a pull request.

@lemingos
Copy link

@lemingos lemingos commented Jan 29, 2021

Try to listen to dispatched events:

my_controller.js

 connect() {
    this.myTarget.addEventListener(`${this.identifier}:world`, this.world.bind(this), {})
  }
  
world() {
  console.log('hello world')
}

In other controller

    this.myTarget.dispatchEvent(new Event('hello:world'))

make sure this.myTarget points to the same element in both controllers

@michalbiarda
Copy link

@michalbiarda michalbiarda commented May 14, 2021

Here's mine approach:

trigger_controller.js

import { Controller } from 'stimulus';

export default class extends Controller {
    static values = {
        eventName: String,
        details: Object
    }

    execute() {
        window.dispatchEvent(new CustomEvent(this.eventNameValue, this.hasDetailsValue ? this.detailsValue : {}));
    }
}

and then in your HTML:

<button data-controller="trigger"
            data-trigger-event-name-value="some:unique:action"
            data-action="trigger#execute">Trigger some action</button>

and:

<div data-controller="some-controller"
         data-action="some:unique:action@window->some-controller#method"
         ...

You can even pass some additional data:

<button data-controller="trigger"
            data-trigger-event-name-value="some:unique:action"
            data-trigger-details-value='{"some": "details"}'
            data-action="trigger#execute">Trigger some action</button>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Linked pull requests

Successfully merging a pull request may close this issue.

None yet