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!