230 Unobtrusive JavaScript components [3d]

Updated . Posted . Visible to the public.

Intro

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.

Thinking in components

Ideally we want to package all our JavaScript in the form of reusable components.

Examples:

  • Date picker: A text field opens a calendar when clicked
  • Modal opener: A link opens its content in a modal overlay when clicked
  • Find-as-you-type search: A search field automatically filters a list while the user is typing in a query
  • Accordion Show archive.org snapshot : A stacked list of items. Each item can be expanded to reveal more details.

Is our movie counter script already a component? Let's go through a checklist:

✔️ Reliable invocation

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.

❌ Can have many copies

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.

❌ Has a limited scope

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.

❌ Applies after dynamic DOM mutations

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.

❌ Allows clean-up

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 .

Tools

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:

We recommend using the compiler() helper for the exercises below.

Exercises

Refactor movie counter

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', ...)
})

Spoilers

  • Add an optional "plot summary" to your Movie records.
  • Plot summaries can be spoilers. Write some JavaScript for your movie detail screen that hides the summary and shows a "Show spoilers" link. When clicked the full content is shown.
  • Implement this using unobtrusive JavaScript. In your HTML, only add a class spoiler to the <div> containing the plot summary, the rest should be done all on the client.
  • Now add a new field "Secret salary" to Actor records. Can you use the same "spoiler" JavaScript to hide the salary on the actor's show view?

Find-as-you-type

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:

  • The <input> for the search query
  • The list of search results that needs to be updated

Now 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 .

Dual-pane layout

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
  })
})

Convert existing JavaScripts

  • Your project probably has some JavaScript snippets from earlier lessons, such as the movies counter.
  • Refactor every piece of JavaScript so it activates selectors with the compiler() helper.
  • Also refactor your JavaScript so you have a single file per component. Each file should be named after the selector it activates:
// 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'.

Henning Koch
Last edit
Michael Leimstädtner
License
Source code in this card is licensed under the MIT License.
Posted by Henning Koch to makandra Curriculum (2015-08-03 17:00)