Feature: Stimulus Portals

I’m not sure if it’s better to post here or directly on the Stimulus Github, but I’ve found a feature that may be helpful in the base Stimulus library. As the title states, “portals”. I don’t know how to get a pulse on if this feature is worth me extracting from my gem and attempting to get it integrated into stimulus.

I’ve started building out a headless ui equivalent with Stimulus + ViewComponent and a fairly crucial feature to pulling it off was React Portals. The name is pretty good for implying the function: have a parent node “see” another node elsewhere on the DOM as a child, even though it’s not a direct descendent. It helps in situations like modals or dropdowns where you want the floating panel to completely break out of any inherited styles and ensure it’s on top of everything.

I built out a fairly complex controller for the gem that hijacks Stimulus internals to provide this functionality when it’s connected to another controller via the outlets API. When the portal_controller is connected to another controller, it automatically makes the original controller see the portal_controller’s element as part of its own content, thus adding actions and targets from the portal controller to the main controller.

I could see this being added as another option to a stimulus controller at the stop, like

static portals = [ "portal_css_selector" ]

And then having the controller automatically know about related targets and actions happening inside the portal.

Anyways, would love to have a discussion about if the community sees any value in this. I have it working in the gem in a decent-enough way but I’d definitely spend the time building it in a more first-party way if it’s valuable to people

1 Like

Thanks for sharing this.

It sounds really similar to the outlets api which is already introduced to allow easy communication between multiple stimulus controllers. I havent seen much content on it so its more of a hidden feature in the library

You might want to check out the docs on them and see if this is relevant Stimulus Reference

Oof, I definitely forgot to mention in the original post that my current implementation uses the outlets API. I edited it so as to be more accurate. Thank you for providing help and you were spot on. Outlets is exactly how I managed my current implementation. They are similar, but I did find outlets to be a bit tedious for the functionality I was after. I can illustrate the difference as I see it with a modal. Some sudo code for outlet implementation:

<div id="modal" data-controller="modal" data-modal-portal-outlet="#portal">
  <button data-action="modal#open">Open</button>
</div>

<div id="portal" data-controller="portal">
  Content
  <button data-action="portal#close">Close</button>
</div>
# Modal Controller
export default class extends ApplicationController {
  static outlets = ["portal"]
  static values = {
    open: Boolean
  }
  
  open() {
    this.openValue = true
    this.portalOutlet.open()
  }
  
  close() {
    this.openValue = false
  }
}

# Portal Controller
export default class extends ApplicationController {
  static outlets = ["modal"]
  static values = {
    open: Boolean
  }

  open() {
    this.element.enter()
    this.openValue = true
  }

  close() {
    this.element.leave()
    this.modalOutlet.close()
  }
}

In an outlet situation, you need to have two controllers coordinating events/actions in order to open/close the modal. You end up with multiple sources of truth and several instances of repetition. This situation would be more ergonomic if we could remove multiple controller in situations where we really want one logical unit

<div id="modal" data-controller="modal" data-modal-portal="#portal">
  <button data-action="modal#open">Open</button>
</div>

<div id="portal" data-modal-item="content">
  Content
  <button data-action="modal#close">Close</button>
</div>
# Modal Controller
export default class extends ApplicationController {
  static portals = ["portal"]
  static targets = [ "content" ]
  static values = {
    open: Boolean
  }
  
  open() {
    this.openValue = true
    this.contentTarget.enter()
  }
  
  close() {
    this.openValue = false
    this.contentTarget.leave()
  }
}

In the portal situations, this.contentTarget would search both the controller’s element AND the portal element for a contentTarget. Same with actions. I think the advantage over an outlet is you maintain one controller and one source of truth.

Hopefully that makes sense. As I said, I mostly monkey patched this ability through the outlets API by having the portal outlet overwrite the getters in the host controller