Stimulus controller reconnection

I’m trying to figure out how to manage state in an application where a stimulus controller is managing HTML elements that can be replaced by the server via a turbo stream broadcast. The UI is fairly simple. It has a checkbox that toggles the visibility of another element. This checkbox state is ephemeral in the sense that it is only needed to hide/show elements on the page and isn’t persisted anywhere.

The stimulus controller is quite simple. It just tracks the state of the checkbox and displays / hides other elements accordingly. The tricky part comes when the server broadcasts a <turbo-stream action="replace" ...>. Coming from the server, the replacement cannot know what the state of the checkbox is. The replacement triggers the elements to be removed from the DOM, the original stimulus controller to disconnect, the replacement HTML to be inserted, and finally a new stimulus controller is constructed and connected.

The core problem here is that any time an HTML element controlled by a stimulus controller is replaced in the DOM (be it via a turbo stream replace or any other mechanism), any state not stored in the HTML is lost when constructing the new controller. It is certainly possible to preserve the state manually and restore it when new controllers are constructed, but I’m wondering if there is a cleaner way. The documentation mentions Reconnection in the lifecycle callbacks, which suggests that perhaps there is a way to reuse a controller rather that constructing a new one. I’m not sure if that would be a possible solution, or how it would deal with merging in state stored in the new html, but I have not observed any reuse of my controllers when replacing HTML.

Does anyone have any suggestions on how to go about solving this in a general way?

Below is an example of how to solve this by manually saving and restoring the state of a stimulus controller for an element. The basic idea is to have a mapping of element id’s to state objects representing the ephemeral (client-side only) state of the controller that we want restored whenever the element is replaced.

import { Controller} from '@hotwired/stimulus';

const savedClientStates: Map<string, { toggleValue: boolean }> = new Map();

export default class extends Controller {
  static values = { toggle: Boolean };
  declare toggleValue: boolean;

  connect() {
    this.restoreClientState();
  }

  disconnect() {
    super.disconnect();
    this.saveClientState();
  }

  saveClientState() {
    savedClientStates.set(this.element.id, { toggleValue: this.toggleValue });
  }

  restoreClientState() {
    const state = savedClientStates.get(this.element.id);
    if (state) {
      this.toggleValue = state.toggleValue;
    }
  }

  // ...
}

It’s fairly straight-forward, but feels a bit clunky. For one, the controller needs to manually deal with representing the state (which is already stored within the controller itself), storing and restoring it. I’m also not super familiar with stimulus and am worried there may edge cases or gotchas that I’m not aware of. If stimulus were to reuse the previous controller on replacement of the HTML element, it would solve all of this. I peeked through the stimulus code a bit, but I couldn’t find any place where it was intelligently detecting the replacement of an element (something I imagine would be tricky to do correctly with the MutationObserver API) and attempting to reuse the already existing controller. That being said, perhaps I’m missing something since the documentation does explicitly mention reconnecting controllers.

I’m curious how this solution looks to those a bit more familiar with stimulus.

Can you share an example of the markup you currently use? That will help us help you.

I’m thinking <turbo-stream action="update"> might help.

I have another idea in mind but I need to see your markup to explain it.

@rik Thanks for having a look at this.

Below is a very simplified version of the markup. Essentially there is a toggle the user can click to hide/show details about the model. This is all hooked up to a turbo stream that can receive the updates from the server which is pushing a replacement of the entire <turbo-frame> element.

<turbo-frame data-controller="my-controller" id="my_model_101">
  <div data-action="click->my-controller#toggleExpanded" data-my-controller-target="expandToggle">
    +
  </div>
  <div>
    Summary of model
  </div>
  <div class="hidden" data-my-controller-target="details">
    Toggleable additional details
  </div>
</turbo-frame>

The controller looks something like

import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
  static targets = ['expandToggle', 'details'];
  declare expandToggleTarget: HTMLElement;
  declare detailsTarget: HTMLElement;

  static values = { expanded: Boolean };
  declare expandedValue: boolean;

  expandedValueChanged() {
    this.refreshToggle();
  }

  toggleExpanded() {
    this.expandedValue = !this.expandedValue;
  }

  refreshToggle() {
    if (this.expandedValue) {
      this.expandToggleTarget.innerText = "-"
      this.detailsTarget.classList.remove("hidden")
    } else {
      this.expandToggleTarget.innerText = "+"
      this.detailsTarget.classList.add("hidden")
    }
  }
}

So without changing the markup, changing the turbo-stream from replace to update should work. Have you tried it?

Also, there’s a native HTML way to do this, without the need for a controller:

<turbo-frame id="my_model_101">
    <details>
        <summary>
            Summary of model
        </summary>
        <div>
            Toggleable additional details
        </div>
    </details>
</turbo-frame>