A common task in web applications is to add client-side JavaScript behavior to existing HTML elements.
For instance, in Working with the DOM you built a movie counter script that's used like this:
<table>
<tr>...</tr>
<tr>...</tr>
</table>
<!-- Clicking shows the number of <tr> elements above -->
<button class="movie-counter">Count movies</button>
Your implementation probably looked like this:
window.addEventListener('DOMContentLoaded', function() {
const button = document.querySelector('.movie-counter')
if (button) {
button.addEventListener('click', function(event) {
event.preventDefault()
const movieRows = document.querySelectorAll('tr')
button.innerText = `${movieRows.length} movie(s)`
})
}
})
While this code works for our simple case, it has many shortcomings.
Ideally we want to package all our JavaScript in the form of reusable components.
Examples:
Is our movie counter script already a component? Let's go through a checklist:
Using the HTML markup above should always result in the same behavior.
Components are usually invoked when a particular CSS selector matches a new DOM element. This can be an HTML class (like .movie-counter
above), an HTML attribute (<div movie-counter>
) or an element name (<movie-counter>
).
This is fulfilled by our movie counter above.
We cannot have more than one movie counter button on the screen.
There should generally be no limit to how many times we can use a component in parallel.
As soon as there is a second table on the screen, the movie counter counts too many rows.
When components observe or change other DOM, there should be a way to limit the scope. Ideally a component only sees or changes its own inner HTML Show archive.org snapshot (descendant elements) and avoids interactions outside its bounding box.
When other JavaScript inserts a new <button class="movie-counter">
element after the initial page load, the code above will not run and the button will not count movies. It only runs once on the DOMContentLoaded
event.
This is important because modern web applications often only have a single page load for the entirety of a user session. All further links, buttons etc. replace fragments within the initial page.
In more complex components we sometimes also want to run clean-up code when an element is removed from the DOM.
For instance, if a component uses
setInterval()
Show archive.org snapshot
it must also
clear
Show archive.org snapshot
that interval when it is removed in order to
prevent memory leaks
Show archive.org snapshot
.
Every modern project needs some way to define components, by pairing JavaScript initialization functions with the right HTML elements.
Some common ways to do this are:
compiler()
helperMutationObserver
Show archive.org snapshot
We recommend using the compiler()
helper for the exercises below.
Refactor your movie counter so it fulfills all properties from the component checklist above.
In particular it should be possible to have two countable lists with one button each on the same page. Each button should only count their respective list. One way to limit the scope is to make the component the smallest rectangle that encompasses all participating elements. In this case it would be an element that contains both the countable items and the count button:
<div class="countable">
<table>
<tr class="countable-item">...</tr>
<tr class="countable-item">...</tr>
<tr class="countable-item">...</tr>
</table>
<span class="countable-button">Count items</span>
</div>
Now make a component for the container instead of the button:
compiler('.countable', function(countable) {
const button = ...
const items = ...
button.addEventListener('click', ...)
})
Movie
records.spoiler
to the <div>
containing the plot summary, the rest should be done all on the client.Actor
records. Can you use the same "spoiler" JavaScript to hide the salary on the actor's show view?Implement a find-as-you-type search box over the movies list in MovieDB. The list of movies should update while the user is typing in the search query.
Note how this time, you have multiple elements that interact with each other:
<input>
for the search queryNow add a similar find-as-you-type search box over the actors list in MovieDB. Can you find a way to re-use the same JavaScript for both lists? For this your JavaScript must be abstract enough to not contain screen-specific references (such as selectors or URLs).
Note that it's OK for the server to send HTML for your JavaScript to consume. It does not need to be JSON.
Tip
You can use
fetch()
Show archive.org snapshot to load a server response into a JavaScript variable, without making a page load. This concept is also known as AJAX Show archive.org snapshot .
Change the movie index so the screen is divided into two vertical panes. Each pane should show a full list of movies and a find-as-you-type box to filter the list by query:
+-----------------------------------------+
| MOVIEDB |
+--------------------+--------------------+
| | |
| MOVIES | MOVIES |
| [ Search… ] | [ Search… ] |
| | |
| - Foo movie | - Foo movie |
| - Bar movie | - Bar movie |
| - Baz movie | - Baz movie |
| | |
+--------------------+--------------------+
The search box above each pane should only filter the pane below. Actions in one pane should never affect the other pane. So this state should be possible:
+-----------------------------------------+
| MOVIEDB |
+--------------------+--------------------+
| | |
| MOVIES | MOVIES |
| [ Foo ] | [ Ba ] |
| | |
| - Foo movie | - Bar movie |
| | - Baz movie |
| | |
| | |
+--------------------+--------------------+
Did your existing JavaScript already behave that way? Then you're well on your way to thinking with components 👍.
If a search in one pane accidentially affects the other pane, you might need to find a more elegant way how the participating elements find each other. Try this structure in your HTML:
<form class="searchable" action="/movies">
<input type="text" class="searchable--query">
<div class="searchable--results">
...
</div>
</form>
Use it with a compiler function like this:
compiler('.searchable', function(searchable) {
const queryField = searchable.querySelector('.searchable--query')
const results = searchable.querySelector('.searchable--results')
queryField.addEventListener('input', function() {
// react to user typing
})
})
compiler()
helper.// in javascript/components/foo.js
compiler('.foo', function(element) {
// activate JavaScript on element
})
// in javascript/components/bar.js
compiler('.bar', function(element) {
// activate JavaScript on element
})
// in javascript/components/baz.js
compiler('.baz', function(element) {
// activate JavaScript on element
})
Tip
You can load another JavaScript with
import 'other_file.js'
.