Rethinking Inheritance

Over the past year we’ve been heads-down working hard on Dojo 2 and its component architecture. The ability to change default component behavior is essential to a widget library, and several tactics exist for doing so. After extensive battle testing of different viable approaches to component modification, we decided to once again equip ES6 inheritance as our primary method of extending component functionality. Here’s why.

Changing the Defaults

Using properties

w(TabController, {alignButtons: Align.right})

Passing different values for specificproperties, like passingAlign.right foralignButton above, allows for efficient behavioral modification of component instances. For example, if a TabController’s tabs are normally left-aligned in an application but are right-aligned in one specific use case, passing in a value foralignButtons is an optimal solution for one-off customization.

Though usingproperties works well for instance-based modification, this practice can be both restrictive and tedious in common application scenarios. For example, if a TabController’s tabs should _always_ be right-aligned throughout an application,alignButtons would need to be explicitly set toAlign.right anytime the component is used, which is both brittle and unmaintainable:

w(TabController, {alignButtons: Align.right,tabs: tabsA}),w(TabController, {alignButtons: Align.right,tabs: tabsB}),w(TabController, {alignButtons: Align.right,tabs: tabsC})

Further, and perhaps more importantly than the verbosity above, modification throughproperties restricts what can and can’t be changed for a given component to only the properties exposed by the widget author. This can be especially problematic when creating widgets intended for wide situational reuse. Unless widget authors explicitly account for the specific modification a downstream developer wishes to accomplish, it’s just not possible usingproperties.

Using compose

We no longer feel those benefits are strong enough to warrant the effort of leveraging and maintaining a hand-rolled inheritance solution.

While compose still offers the same benefits today that it did a year ago, we no longer feel those benefits are strong enough to warrant the effort of leveraging and maintaining a hand-rolled inheritance solution. While compose does provide a concept of mixins that can help satisfy certain requirements around multiple inheritance, this approach is still secondary to native support. Because Dojo 2’s component architecture is highly reactive, several similar secondary approaches to multiple inheritance exist, including TypeScript-enabled decorators and mixing together higher order components. Because new approaches to combining functionality from multiple components have negated the advantages that multiple inheritance once provided, and because we’ve grown to embrace the simplicity of singular-inheritance, this key benefit of compose is no longer as useful for our needs.

We’ve been able to thoroughly test our original compose-based inheritance approach, and while its API is concise, the static type checking provided by TypeScript mitigates most of the same developer pitfalls compose helped to safeguard against. The fact that TypeScript allows many tight-coupling maintenance issues to be immediately identified and potentially mitigated is a powerful feature of a statically-typed language, and a key reason why compose is no longer the best tool for Dojo 2.

Using higher order components

v('div', { key: 'some-tabs' }, [w(createTabs(tabsA))]),v('div', { key: 'some-more-tabs' }, [w(createTabs(tabsB))]),v('div', { key: 'some-other-tabs' }, [w(createTabs(tabsC))])

The only parameter for this example function is tabs so that different tab arrays can be passed into each wrapped TabController, but any number of parameters could be used:

function createTabs(tabs) {return class extends WidgetBase {protected render(): DNode {return w(TabController, {alignButtons: Align.right,tabs});}};}

This pattern has several advantages. It allows for the modification of components through properties without the lack of maintainability caused by explicitly passing in the same property values every time a component is used, as seen above. This pattern also effectively allows for shared logic between components by returning a wrapped component that can contain common code; the generating function can accept a component class as a parameter for greater internal rendering decoupling:

function createAutoSaveComponent(Component) {return class extends WidgetBase {save() {// common save logic}protected render(): DNode {return w(Component, {onClose: save});}};}

Despite the flexibility and maintainability advantages that higher order components can provide, they still inherently rely on properties to customize the underlying component that’s wrapped. Again, this could be a limitation when authoring reusable components: because inheritance isn’t used to copy behavior from the underlying component, the same property-based restrictions as to what downstream developers can and can’t modify still apply even within higher order components.

So, ES6 Inheritance?

class MyTabController extends TabController {renderTabButtons() {return this._tabs.map((tab, i) => {return w('Radio', {// ...})};}}

renderTabButtons could be overridden to provide a modified DNode[] and static type checking enforces that the overridden method signature matches.

TypeScript === Maintainability

In the context of Dojo 2 widgets written in TypeScript, the maintainability argument isn’t applicable; intellisense and tsc will indicate that name clashes or errors exist when downstream developers change component code that extends our default components. This is a powerful advantage of using static type checking. TypeScript goes further and allows our components to strictly adhere to method visibility patterns, only exposing methods intended to be overridden using the protected keyword, giving downstream developers clear, self-documented extension points.

The type of modification provided by inheritance, even inheritance controlled by member visibility keywords, does come with special considerations. In Dojo 2, it’s almost never correct to extend the render method of a pre-fabricated component. As such, most of a component’s render method should off-load element generation to helper methods:

protected renderResult(result: any): DNode {const { getResultLabel } = this.properties;return v('div', [ getResultLabel(result) ]);}render(): DNode {const {isDisabled,result,selected} = this.properties;return v('div', {'aria-selected': selected ? 'true' : 'false','aria-disabled': isDisabled(result) ? 'true' : 'false',classes: this.classes(css.option,selected ? css.selected : null,isDisabled(result) ? css.disabledOption : null),'data-selected': selected ? 'true' : 'false',onmousedown: this._onMouseDown,onmouseenter: this._onMouseEnter,onmouseup: this._onMouseUp,role: 'option'}, [this.renderResult(result)]);}

This example code is from the ResultItem for the Dojo 2 ComboBox; renderResult would be extended, shielding downstream developers from having to reimplement the mostly-internal-specific render method. This pattern of off-loading element generation logic to helper methods allows for safer component extension using simpler method signatures.

Bonus: Working with the Registry

class CustomTabButton extends TabButton {renderButton(result: any) {return w('Radio', {// ...});}}w(TabController, {customTabButton: CustomTabButton,// ...}),

Conclusion

Learning more

Get help from SitePen On-Demand Development, our fast and efficient solutions to JavaScript development problems of any size.

Let’s talk about how we can help your organization improve their approach to automated testing.

Have a question? We’re here to help! Get in touch and let’s see how we can work together.

Originally published at www.sitepen.com on September 19, 2017.

Modernizing Apps, Tools & Teams | sitepen.com | Twitter: @sitepen