Nested components
Even if you can, you should not write components that are too large, because this leads to poorly maintainable code.
Instead, you should break a large component into several small ones that are merged by the formerly large one. This concept is called nested components. If we apply this approach recursively, we can build a whole app by nesting Hof.js components.
To illustrate this idea, lets write a simple app that performs a live conversion between decimal and hex while the user is typing:
- The user should be able to enter either a decimal or a hex number and the updated value should appear in the other input field while he is typing.
- If invalid values are entered instead of numbers, an error message should appear.
- The app should look like the following (interactive) illustration.
Rendered webpage
To make this work, we are going to divide the task into multiple components:
- Component
number-converter-app: Implements html of whole webpage body. In its own template it contains a heading and anumber-convertertag. - Component
number-converter: Represents ui and logic of number conversion - it is composed of:- a
number-input.jscomponent to display and input a decimal value - another
number-input.jscomponent to display and input a hex value - additional
number-infocomponent to render an error message if the entered value is not valid
- a
First, lets take a look at our webpage markup. This is pretty simple because the whole functionality and rendering html is encapsulated in our number-converter-app component.
number-converter-app.html
<!DOCTYPE html>
<html>
<head>
<title>Number converter app</title>
<script src="../../lib/nomodule/hof.js"></script>
<script src="number-converter-app.js"></script>
<script src="number-converter.js"></script>
<script src="number-input.js"></script>
<script src="number-info.js"></script>
</head>
<body>
<number-converter-app></number-converter-app>
</body>
</html>
Note
This sample webpage contains a script tag for each component that is used either in the html markup of the webpage or in the template of another component.
This is not recommended. In real applications, you should either bundle all script files so that only one script tag is needed for all components or, even better, rely on modular JavaScript.
Hof.js supports this and includes an ESM version of the framework in its npm package. When switching to modular JavaScript, only the root component needs to be included in the HTML web page, since each component can itself include required other components via import.
Our root component number-converter-app is similarly simple. It outputs a heading for our webpage / app and renders a number-converter component.
number-converter-app.js
customElements.define("number-converter-app", class extends HofHtmlElement {
templates = html`
<h1>Number converter app</h1>
<number-converter></number-converter>
`
})
Component number-converter is a lot more complex. It contains all state, logic and rendering for our number conversion:
- Property
valuerepresents the initial or last number entered by the user. - Property
scalerepresents the number system of the initial or last number entered by the user. - Method
changeapplies new values for propertiesvalueandscale. - In the template two
number-inputcomponents and onenumber-infocomponent are rendered:- The first
number-inputrenders a label for decimal input and an input field to accept a decimal value provided by the user. - The second
number-inputrenders a label for hex input and an input field to accept a hex value provided by the user. - The
number-infocomponent shows a error if a number provided by the user is not valid.
- The first
number-converter.js
customElements.define("number-converter", class extends HofHtmlElement {
value = 16;
scale = "d";
change(value, scale) {
this.value = value; this.scale = scale;
}
templates = html`
<div>
<number-input scale="d" value="${this.scale == 'd' ? this.value : parseInt(this.value, 16)}" change="${this.change}"></number-input>
<number-input scale="h" value="${this.scale == 'h' ? this.value : parseInt(this.value).toString(16)}" change="${this.change}"></number-input>
<number-info value="${this.scale == 'd' ? this.value : parseInt(this.value, 16)}"></number-info>
</div>
`
})
State is passed down from our number-converter component to its child components:
- To reuse
number-inputfor decimal and hex, different values forscaleandvalueare passed down. - By checking the
scaleproperty, the format of the number entered and stored in thevalueproperty is checked. Then, depending on the input field, the current value is taken or converted to the other format and passed down to the correspondignumber-input. - Similarly, the current format stored in
scaleis passed down so that the respectivenumber-inputcan output a suitable label like “decimal” or “hex” in front of the input field to be rendered. - Please note that our
changefunction is passed down. This can then be called from anumber-inputwith the new value entered by the user and the number format of the respectivenumber-inputcomponent. - Similarly, the decimal representation of the current value is passed down to
number-infoso that it can check the input to see if it is a valid number and output an error message if necessary.
Now lets take a look at component number-input.
number-input.js
customElements.define("number-input", class extends HofHtmlElement {
value = null;
scale = null;
change = null;
_SCALE_NAMES = { d: 'Decimal', h: 'Hex' }
templates = html`
<label>
${this._SCALE_NAMES[this.scale]}:
<input value="${this.value}" oninput="${(event) => this.change(event.target.value, this.scale)}"/>
</label>
`
})
The number-input component renders the current value and a label that shows “Decimal” or “Hex” depending on the scale property:
- This means the first
number-inputin the template of thenumber-convertercomponent is going to render “Decimal” because value “d” was passed down. - In contrast, the second
number-inputrenders the label “Hex” since the value “h” was passed down. - Finally, the
valueobtained from the parentnumber-convertertag is displayed in aninputfield.
What happens now when the user types in a new value in an input field?
- First, the passed down
changemethod from the parentnumber-converteris called. The current value provided by the user and number format of theinputfield are passed up to thenumber-converter. - As a consequence the parent
number-converterupdates its properties with the newvalueandscalereceived from the childnumber-input. - This leads to a rerendering of the parts of the
number-convertertemplate that depend on these properties. This affects the twonumber-inputchild components, which receive the new (converted)valuevia their properties and thus re-render the affected parts of their templates on their part. Finally, they show the new (converted) number.
Finally, lets take a look at the component number-info.
number-info.js
customElements.define("number-info", class extends HofHtmlElement {
value = null;
templates = () => isNaN(this.value, this.scale) ? `<p>No valid number!</p>` : ``
})
This component ist pretty simple. It accepts a decimal number and checks if its a valid one. If this is the case it returns an empty string. Otherwise an error message is rendered.
And thats it.
You have just implemented a simple app that demonstrates nesting of components and unidirectional dataflow!
What we have learned
- State (including references to event handlers) is passed down from parent to child components.
- Events from a child component go up to its parent component.
- This leads to the following unidirectional flow:
- Initial state of parent component is passed down and rendered by the child components.
- If a user changes something by interacting with a child component, a parent component event handler is called with the new value(s) provided by the user.
- As a result the parent updates its properties.
- This leads to a rerendering of the parts of the parent component template that depend on these properties. These are the child components, which thus receive new values via their properties and thus in turn re-render the parts of their templates that depend on them.