Stimulus Discourse

beforeConnect method to avoid super.connect()?


#1

I have been working on a few standard controllers recently. They are usually controllers adding standard behaviors with the ability to extend them to add more customs features

Typically something like :

import { Controller } from "my-standard-stimulus-controller";

export default class extends Controller {
  connect() {
    super.connect();
    //some custom code
  }
}

If in my base controller I need to do something to initialize during connect or initialize in my controller then when I extend this controller I must call super.connect();

Is there a way to avoid this to make it easier for users to extend a controller without thinking about always calling super.connect()super.disconnect() etc?

Would it make sens to have as par of the base API some beforeConnect()& afterConnnect() methods to handle this?

Do you see a way to do it with the current API?


#2

I don’t think it’s possible to have a method do an implicit call to super, so you’re left with some sort of before/after callbacks like you suggested.

I think the names of {before|after}Connect could cause some confusion because they imply that the callbacks are invoked before and after the element is connected to the DOM, when they are really just wrapping around the parents connect method.

It might be the simplest and most flexible solution to just require a call to super in the extending controller. That’s common practice anyway. If you don’t think that’s acceptable, maybe consider naming the callbacks something more intention revealing?

By the way, I really appreciate your contributions to the Stimulus ecosystem. We use stimulus-flatpickr on one of our projects at work, and it works really well :slight_smile:


#3

I agree the name could be confusing

I agree that it is a common practice, I just had several cases of users having bugs because they forgot calling super. I was wondering if there is anything we could do to make the DX even better.

Great thanks for the feedback!


#4

I’ve thought about it some more and I feel like every solution that tries to abstract away the call to super has some serious drawbacks. Maybe there exists some solution that I just don’t have the imagination to realize.

You could invent a new connect/disconnect API for subclasses:

// standard_controller.js
connect() {
  // Perform some setup tasks
  this.connected()
}

connected() {
  // Overwrite in subclasses
}

disconnect() {
  // Perform some teardown tasks
  this.disconnected()
}

disconnected() {
  // Overwrite in subclasses
}

Which would make it possible to use the custom API in classes that extends from the standard controller:

// extended_controller.js
connected() {
  // Do what you would normally do in `connect`
}

disconnected() {
  // Do what you would normally do in `disconnect`
}

While this would work, I feel like inventing new API’s kinda defeats the purpose of creating “standard” controllers to rely on. And while users don’t have to remember to call super they have to know that some specialized callbacks exist. If people are already forgetting to call super they’ll surely forget that the connect/disconnect/after/before callbacks are there or how to use them.

I’m excited to see what you come up with!


#5

I’ve come across a similar issue and have taken some inspiration from React hooks to try and improve things a little. Let me try and explain here.

Firstly, all of the controllers inherit from a BaseController class. Amongst other things, this contains the following code:

import { Controller } from 'stimulus';

// Intentionally an object so that === will compare against this exact reference.
// We could use a Symbol here, but IE11 will barf and I don't think it merits a full on polyfill.
const initializeReceipt = {};

export default class BaseController extends Controller {
  connectors = [];
  disconnectors = [];
  connectionDisconnectors = [];

  /**
   * Instead of using regular Stimulus lifecycle methods, we'll use these.
   */ 

  // This is called by `initialize`.
  onInitialize() {
    // This receipt is checked by `initialize`.
    // This ensures that any implementing method must call down the prototype chain,
    //  so that initializers are not skipped.
    // https://github.com/Microsoft/TypeScript/issues/21388#issuecomment-360214959
    return initializeReceipt;
  }

  /**
   * Adds a 'connector' function, which is run when the controller is connected.
   * This connector function can optionally return a 'disconnector' function,
   * which will be run when the controller is disconnected.
   * This couples the connect/disconnect lifecycle together for a particular operation.
   * (Inspired in part by the `useEffect` React hook).
   *
   * @param fn a connector function, when run optionally returns a 'disconnector' function
   */
  onConnect(fn) {
    this.connectors.push(fn);
  }

  /**
    * Adds a 'disconnector' function, which is run when the controller is disconnected.
    *
    * @param fn a disconnector function
    */
  onDisconnect(fn) {
    this.disconnectors.push(fn);
  }

  /**
   * These lifecycle methods are provided by Stimulus.
   * We won't use these directly, instead we will call helpers which make it easier and safer to hook in to the controller lifecyle.
   */
  initialize() {
    const receipt = this.onInitialize();
    if (receipt !== initializeReceipt) {
      // If we get here, it means that a subclass did not call `super.onInitialize()`.
      // This guard therefore ensures that every subclass should call `super.onInitialize()`.
      throw new Error(
        `onInitialize was implemented in ${this.identifier} without a call to super.onInitialize.`
      );
    }
  }

  connect() {
    // Run all connectors. If any return disconnectors, cache them for later use.
    this.connectionDisconnectors = this.connectors.reduce(this.runConnector, []);
  }

  disconnect() {
    // Run any permanent disconnectors.
    this.disconnectors.forEach(this.runDisconnector);

    // Run any disconnectors that were returned from connectors,
    // and clear the cache ready for the next run.
    this.connectionDisconnectors.forEach(this.runDisconnector);
    this.connectionDisconnectors = [];
  }

  /**
   * Helper methods for running connectors and disconnectors.
   */

  runConnector = (disconnectors, connector) => {
    const d = connector();
    if (typeof d === 'function') {
      disconnectors.push(d);
    }
    return disconnectors;
  };

  runDisconnector = (d) => {
    d();
  };
}

The idea is that instead of using the regular initialize, connect and disconnect, there are three new methods: onInitialize, onConnect and onDisconnect. Further to this, the only method that should be overridden is onInitialize.

This is how a controller class would extend the BaseController:

import BaseController from 'src/base_controller';

export default class ExampleController extends BaseController {
  onInitialize() {
    // Add a 'connector' by calling `onConnect`.
    // The connector function will be called by BaseController
    // when the controller is connected to the DOM.
    this.onConnect(() => {
      const interval = setInterval(this.performSomeWork, 1000);
      // Return a 'disconnector' function, which will be called by BaseController
      // when the controller is disconnected from the DOM
      return () => {
        clearInterval(interval);
      };
    });

    // Add a standalone 'disconnector' by calling `onDisconnect`.
    // The disconnector function will be called by BaseController
    // when the controller is disconnected from the DOM.
    // Note that if there is some related logic that is performed on connect and disconnect,
    // you should instead return a disconnector from `onConnect` as above.
    this.onDisconnect(() => {
      this.fireDisconnectionEvent();
    });

    // Finally we must remember to call the superclass method,
    // to ensure that other connectors/disconnectors in the inheritance chain are called.
    // We'll get a runtime exception if we forget to do this.
    return super.onInitialize();
  }
}

There are a couple of advantages to this approach:

  • Oftentimes, connect/disconnect logic comes in pairs. One example is setting up a subscription - with regular stimulus this would require the use of an instance property to store an ‘unsubscribe’ function. By defining an onConnect which can return a disconnector, this allows a regular local variable to be used instead for the unsubscription handle. (This has been heavily influenced by React’s useEffect hook).
  • initialize in the base controller has a check to ensure that the base onInitialize method it is called (via a triple-equals check to a private object in the base class). Provided the developer uses the onInitialize method, this will ensure that super.onInitialize() is always called in subclasses.

I’ve been using this in my codebase for about three months and it seems to be working well. Let me know what you think!