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
Since we’re building a statically-typed reactive component framework, the ability for downstream developers to modify default behavior isn’t just a nice-to-have, it’s absolutely essential to Dojo 2’s usability in real-world applications. In order to understand why inheritance works so well for Dojo 2 components in particular, let’s first review other approaches that exist for modifying widget behavior:
Using properties
The most obvious form of Dojo 2 component customization comes through the use of a widget’s public API exposed through its properties
interface:
w(TabController, {
alignButtons: Align.right
})
Passing different values for specific properties
, like passing Align.right
for alignButton
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 for alignButtons
is an optimal solution for one-off customization.
Though using properties
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 to Align.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 through properties
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 using properties
.
Using compose
As detailed in our “Getting Classy with Compose” post from over a year ago, compose
can provide benefits over ES6 inheritance by embracing a functional approach to class reuse instead of traditional class hierarchies. An advantage to this functional API is its ability to support multiple mixins and thus a form of multiple inheritance. By exposing a limited API that allows for factories to be combined and reused, compose
helped safe-guard developers from hard-to-reason inheritance chains because no class hierarchy actually exists in composed factories. For example, the super
keyword is intentionally unavailable because newly-composed factories have no reference to the old factories that created it. This type of architecture can help lessen some of the coupling between classes, a common pitfall when dealing with ES6 inheritance.
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
A common solution to sharing code between components and also to solving the verbosity and maintainability issues above is through the use of higher order components. Using this pattern, a function is called that returns a new component that wraps an underlying component that can have conditional behavior or rendering based on function arguments. For example, continuing with the TabController example above, if a TabController’s tabs should always be right-aligned throughout an application, a higher order component could be used to abstract away the alignButtons
property:
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?
ES6 class-based inheritance provides another mechanism to customize the behavior and UI of a component in a reusable manner. Because Dojo 2 components are already authored using inheritance by way of WidgetBase
and other base classes, the pattern of extending a component class and overriding members is a familiar one for downstream developers. Say an application had a requirement to use native HTML radio inputs instead of default Dojo 2 TabButtons to control tabs:
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
Popular functional frameworks like React have warned against using inheritance in certain situations for key reasons. Extending many classes can introduce layers of chained complexity that can make it difficult to determine where existing functionality lives or new functionality should be added. This point isn’t specific to functional programming and is heavily backed up by Dojo 1’s architecture of circular mixins and base classes. Second, React warns that inheritance introduces implicit, tightly-coupled code dependencies, such as a base class calling a method in the parent class or vice versa. React implies that this tight coupling between base and parent class is brittle and leads to unexpected naming errors when changing member names, which isn’t maintainable.
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
The Dojo 2 widget system provides the concept of a registry
that can be used to swap out classes used internally by a component. For example, if the TabController made use of an internal registry, it may accept a customTabButton
property that could be any class that implements the TabButton interface. A registry is a powerful tool for providing entire components to another component for internal use, a form of component modification that allows entire portions of a component to be modified at once. Because most uses of the registry involve providing a component class that extends some default component class, inheritance again feels natural:
class CustomTabButton extends TabButton {
renderButton(result: any) {
return w('Radio', {
// ...
});
}
}
w(TabController, {
customTabButton: CustomTabButton,
// ...
}),
Conclusion
Component modification is a central feature to any successful widget framework designed for enterprise use. While a hand-rolled solution like compose
does provide many benefits, its usefulness is lessened in the context of a the Dojo 2 widget system that’s statically typed. By using strict TypeScript member visibility and method signatures, a high degree of security can be added to traditional ES6 inheritance to gain the benefits and discipline of compose
-style development, while using a native approach that works well with Dojo 2-specific features like the component registry. Inheritance is the most effective and natural form of customization in Dojo 2, and one that embraces the language features provided by ES6+ and TypeScript.