Share targets between controllers

I’m trying to build a complex component, and would like to break it down into multiple Stimulus controllers to make my work more manageable. Each of these controllers will handle their own small part of the component, but they share a target called fragment.

I started by building a ParentController that looks a bit like this:

import { Controller } from 'stimulus';

export default class extends Controller {
  static targets = ['fragment'];

  get visibleFragments(){
    return this.fragmentTargets().filter(fragment => !fragment.classList.contains('hidden'));
  }
}

Then I started making my other controllers inherit from it:

import ParentController from './parent_controller';

export default class extends ParentController {
  static targets = ['fragment'];

  someAction () {
    this.visibleFragments.forEach((fragment) => {
      // Do something
    });
  }
}

This approach has two problems:

  1. Every child controller must declare ‘fragment’ as a target (static targets = ['fragment']);
  2. Every fragment element must declare its a target of said controller (data-target="child1.fragment child2.fragment);

So what I’d like to know is:

  1. Is there a way to make child controllers inherit their parent’s targets? What if they also declare their own targets?
  2. Is there a way to change the which prefix Stimulus uses in the querySelector to search for the targets? (parent.fragment instead of child.fragment)

Hey brenogazzola,

I know this reply comes late, but better late than never! I don’t have certain answers for either of your questions, but my best guess to both is no, those things currently aren’t easily possible (but might be interesting additions to Stimulus).

However, I’ll propose that parent classes may not actually be what you want, here. It’s hard to tell from the simplified example code, but you may want to be looking into composition rather than inheritance. That is, mixing in the behaviors you want rather than extending a parent class to get those behaviors. I’m not up on the current state of composability in JS, so I can’t offer any concrete examples, but imagine something such as:

import visibleFragments from './fragments';

export default class extends ParentController {
  static targets = ['fragment'];
  
  visibleFragments = visibleFragments

  someAction () {
    this.visibleFragments.forEach((fragment) => {
      // Do something
    });
  }
}

There’s likely a more elegant way to do this in JS these days, especially if you want to mix in a handful of methods, but think of it like including a module into a Ruby class rather than extending a parent class.

Not sure if this is helpful, but I’d be curious to know where you ended up!

@brenogazzola, upon taking a second look, it looks like the first part of this it totally possible. Example below. The second part, however, doesn’t seem to be possible and I can imagine there could be edge cases and or just general confusion resulting from an approach. Depending on what exactly you’re trying to do, mixins could still be a good solution. I’ll post an example of that next.

For target inheritance, this will do the trick.

parent_controller.js:

import { Controller } from 'stimulus'

export default class extends Controller {
  static targets = ['test']

  foo() {
    console.log("Parent.foo()")
  }
}

child_controller.js:

import parent_controller from "./parent_controller"

export default class extends parent_controller {
  connect() {
    console.log(this.constructor.targets)
    console.log(this.testTargets)
    this.foo()
  }
}

And the corresponding HTML:

<div data-controller="child">
  <div data-target="child.test">1</div>
  <div data-target="child.test">2</div>
</div>

You can even extend your targets in the child as such:

  // Now your child targets will be ['test', 'foo']
  static targets = parent_controller.targets.concat(['foo'])

It’s worth noting for anyone not using the standard Rails/Webpacker/Stimulus pipeline that static properties/class fields are not yet a supported part of the JS spec but are supported by Babel are are on track for broad adoption.

Now for an example using mixins… depending on what you’re trying to do, this may or may not be applicable (but could be helpful for others).

In lib/fragmentable.js:

export const targets = {
  targets: ['fragment']
}

export const fragmentable = {
  getVisibleFragments: function() {
    return this.fragmentTargets().filter(fragment => !fragment.classList.contains('hidden'));
  }
}

Child controller:

import { targets, fragmentable} from '../lib/fragmentable'

class ChildController extends Controller {
  someAction() {
    this.getVisibleFragments.forEach((fragment) => {
      // Do something
    })
  }
}

// Mixin class-level ("static") properties
Object.assign(ChildController, targets)
// Mixin instance-level properties
Object.assign(ChildController.prototype, fragmentable)
export default ChildController

Note that Object.assign can take multiple mixins, so you could have something like:

Object.assign(ChildController.prototype, fragmentable, fullscreenable, loggable)

and mixin capabilities from all three of those mixins. Also note that each mixin can have as many or few properties and functions as you’d like.

A more “realistic” example might look something like this:

import { Controller } from 'stimulus'
import { resizeable, sortable, draggable, editable } from '../lib/editing_mixins'

class AdminFragmentController extends Controller {
   static targets = ['fragment']
}

Object.assign(AdminFragmentController, resizable, sortable, draggable, editable)
export default AdminFragmentController

Then say non-admin users should not be able to perform certain functions… but can request access to do so:

import { Controller } from 'stimulus'
import { resizeable, sortable, access_requestable } from '../lib/editing_mixins'

class StandardFragmentController extends Controller {
   static targets = ['fragment']
}

Object.assign(RestrictedFragmentController, resizable, sortable, access_requestable)
export default RestrictedFragmentController

As you can see, this provides a level of composability that simple inheritance does not. I hope this is helpful and inspires some creative (and more maintainable and easier to test) approaches to tricky problems!

1 Like

I believe the following works:

import { Controller } from 'stimulus';

export default class extends Controller {
  static targets = ['fragment'];
}

and parent:

import { Controller } from 'stimulus';

export default class extends Controller {
  static targets = super.targets.concat(['foobar']);
}