Search code examples
javascriptstimulusjshotwire-rails

Best practices for handling UIState in StimulusJS


Just starting off with StimulusJS and trying to follow best practices. As I come from VueJs/Nuxt, state management and communication between components is quite different and I am aiming to follow the StimulusJS way.

Case:

I have a Toggle button that toggles a Sidebar in an e-commerce catalog. Most toggles implemented on the site have a simple and close relation from the toggle button to the toggle target. However, the sidebar lives far from the Sidebar HTML structure-wise. What would be the best approach? Both the toggle button and the toggle target will have a class toggled (.is-open)

  1. Move the controller up to simply have all elements to control as children The problem with this approach is that the JS Data hooks are spread over multiple templates and does not feel componenty

  2. Create two controllers that communicate through events A SidebarToggle (which inherits from my base toggle controller) and a SidebarController that listens.

  3. Bubble up an event to window and make all components that need to catch this even on @window

  4. Something else?

Dummy markup below:

<section class="o-product-catalog mb-60px" data-controller="catalog">
   <div>
      <!-- Header -->
   </div>

   <section class="c-catalog-navigation container">
      <div class="c-catalog-filter-toggle">
         <p class="c-catalog-filter-toggle__label"><span ></span>Show/Hide Filters</p>
         <button class="c-catalog-filter-toggle__button"></button>
      </div>

      <div class="flex">
         <!-- Pagination.. -->
      </div>

   </section>

   <div class="container flex relative">
      <div class="Filters Sidebar">
         <div class="Catalog Facet"></div>
         <div class="Catalog Facet"></div>
         <div class="Catalog Facet"></div>
         <div class="Catalog Facet"></div>
      </div>

      <div class="Catalog Results">
         <div class="Catalog Item"></div>
      </div>
   </div>

</section>

Solution

  • When building things with Stimulus, I find it is always good to start with the HTML as described in the documentation.

    When building accessible HTML, you will find that the answers get a bit clearer. Your 'toggle' button will need to use aria-controls to advise browsers what sidebar is being controlled via an id.

    From here, you have a built in reference from the button to the sidebar which you can leverage in the Stimulus code.

    This also makes it much clearer, when looking at the DOM, what is happening.

    As for communication to the other controller, you are on the right track in thinking with Browser events but it is best to scope events to the DOM elements that need them where possible.

    Example

    HTML

    • See below, we have the same basic DOM but are using aria-controls to reference the id 'catalog-sidebar' of the sidebar we want to control.
    • I have added a separate close button within the sidebar to show that we basically just use the same data-action method to toggle, whether it is triggered by click or the dispatched event.
    <section class="o-product-catalog mb-60px" data-controller="catalog">
      <div>
        <!-- Header -->
      </div>
    
      <section class="c-catalog-navigation container">
        <div class="c-catalog-filter-toggle">
          <p class="c-catalog-filter-toggle__label">
            <span>Show/Hide Filters</span>
          </p>
          <button
            class="c-catalog-filter-toggle__button"
            aria-controls="catalog-sidebar"
            data-controller="sidebar-toggle"
            data-action="sidebar-toggle#toggle"
          >
            Show
          </button>
        </div>
    
        <div class="flex">
          <!-- Pagination.. -->
        </div>
      </section>
    
      <div class="container flex relative">
        <div
          class="Filters Sidebar"
          id="catalog-sidebar"
          aria-expanded="true"
          data-controller="sidebar"
          data-action="sidebar-toggle:toggle->sidebar#toggle"
        >
          <button type="button" data-action="sidebar#toggle">Close</button>
          <div class="Catalog Facet"></div>
          <div class="Catalog Facet"></div>
          <div class="Catalog Facet"></div>
          <div class="Catalog Facet"></div>
        </div>
    
        <div class="Catalog Results">
          <div class="Catalog Item"></div>
        </div>
      </div>
    </section>
    

    JavaScript

    • Both controllers are in the same code snippet below.
    • Sidebar has a toggle method that will change the aria-expanded value (so we keep accessibility in mind).
    • SidebarToggle has a toggle method (naming things is hard) that dispatches an event to the found sidebar in the connect method.
    import { Controller } from '@hotwired/stimulus';
    
    /**
     * A sidebar (expanding menu).
     */
    class Sidebar extends Controller {
      connect() {
        // ...
      }
    
      toggle() {
        const isExpanded = !!this.element.getAttribute('aria-expanded');
        this.element.setAttribute('aria-expanded', isExpanded ? 'false' : 'true');
      }
    }
    
    /**
     * A button which will toggle another sidebar elsewhere in the DOM.
     */
    class SidebarToggle extends Controller {
      connect() {
        this.sidebar = document.getElementById(
          this.element.getAttribute('aria-controls')
        );
    
        if (!this.sidebar && this.application.debug) {
          console.error('should find a matching sidebar');
        }
      }
    
      toggle() {
        this.dispatch('toggle', { target: this.sidebar });
      }
    }
    
    export { Sidebar, SidebarToggle };
    

    Note: I have not run this code locally but it should be pretty close.