|
| 1 | + |
| 2 | +--- |
| 3 | +title: Interactivity with JavaScript in the browser |
| 4 | +template: splash |
| 5 | +--- |
| 6 | + |
| 7 | +In previous chapters, you've seen how to use JavaScript on the server to dynamically generate multiple pages of HTML. For most simple websites, that's all you need. If you want it to look fancy, invest in learning more about design and CSS. |
| 8 | + |
| 9 | +However, sometimes you want to add more interactivity. If you have a server running, you can get quite far with [HTML forms](https://eloquentjavascript.net/18_http.html) and then sending different HTML to different users. But that's not an option for a statically generated website. And for certain sorts of interactions, you don't want it to go through a page navigation (e.g. submitting a form and seeing the result page). Instead, you want the change to be immediate, and affect the page you're currently on without reloading it, to keep your scroll and cursor positions etc. That's when you should use client-side JavaScript – i.e. JavaScript running in the user's browser. A common example is to build a simple to-do list app. |
| 10 | + |
| 11 | + |
| 12 | +## A minimalistic to-do list app |
| 13 | + |
| 14 | + If you want to reuse the `Layout` component like in previous chapters, create a new route (e.g. `routes/todo-list.server.js`). Or alternatively create a new HTML file: |
| 15 | + |
| 16 | +```html title=routes/todo-list.html |
| 17 | +<!doctype html> |
| 18 | +<title>My To-Do list</title> |
| 19 | + |
| 20 | +<form> |
| 21 | + <input placeholder="Enter new to-do here"> |
| 22 | + <button>+</button> |
| 23 | +</form> |
| 24 | + |
| 25 | +<ul id="todos"> |
| 26 | +</ul> |
| 27 | + |
| 28 | +<style> |
| 29 | + body { |
| 30 | + font-family: sans-serif; |
| 31 | + } |
| 32 | +</style> |
| 33 | + |
| 34 | +<script type="module"> |
| 35 | + const input = document.querySelector("form > input"); |
| 36 | + const todos = document.querySelector("#todos"); |
| 37 | + document.querySelector("form").addEventListener("submit", (event) => { |
| 38 | + event.preventDefault(); |
| 39 | + const li = document.createElement("li"); |
| 40 | + li.innerHTML = '<input type="checkbox"> '; |
| 41 | + li.append(input.value); |
| 42 | + todos.prepend(li); |
| 43 | + input.value = ""; |
| 44 | + }); |
| 45 | +</script> |
| 46 | +``` |
| 47 | + |
| 48 | +Check out the minimalistic to-do list in the Mastro preview pane by navigating to `/todo-list` and add a few to-dos. |
| 49 | + |
| 50 | +In HTML5, the `head` and `body` tags can be omitted and will be created by the browser. Check it out in the elements inspector in your browser's dev tools! |
| 51 | + |
| 52 | +If you have relatively little CSS and JavaScript, you can put them directly in the HTML, like above. Otherwise it might make more sense to put them in a separate file and load them with: |
| 53 | + |
| 54 | +```html |
| 55 | +<link rel="stylesheet" href="/styles.css"> |
| 56 | +<script type="module" src="/script.js"></script> |
| 57 | +``` |
| 58 | + |
| 59 | +The HTML elements on the page are made availlable to JavaScript as the DOM (document object model). For example, the `document.querySelector` method returns the first element that matches the specified CSS selector. Above, we use `document.querySelector("#todos")` to select the element with `id="todos"`. You can try writing that in the JavaScript console of your browser's dev tools and see what it returns. Using an `id` is a common technique to mark up an element on a page for JavaScript to find. Be aware however that there can be only one element on a page with any given `id`. If you want to mark up multiple elements, you should use `class="myClass` and look for them with `document.querySelectorAll(.myClass)`, which returns a list of all matched elements. |
| 60 | + |
| 61 | +Next we pass a callback function to the `addEventListener` method. Our function gets called when the form is submitted – i.e. when the user enters some text and hits enter or clicks a button inside the form. Our function gets passed an `event` object, which we use to prevent the default action of a form submission – i.e. we prevent that the form is submitted to a server and the browser navigates away. |
| 62 | + |
| 63 | +Instead, we handle it in the client by creating a new list item element `li` and set its HTML contents to a checkbox using `innerHTML`. The `append()` method adds another element or some text at the end of an element. In our case, we append the `input` element's text value to the list item we already have. |
| 64 | + |
| 65 | +Be aware to never use `innerHTML` on untrusted input. You can try changing the code to just `li.innerHTML = '<input type="checkbox"> ' + input.value;` and then enter some HTML text into the text field of our app as a user. It's inserted as HTML, breaking our page! Definitely not what we want. On the other hand, `append()` escapes the string properly. |
| 66 | + |
| 67 | +Then, using `prepend()`, we add our list item to the top of the `<ul id="todos">` element. And finally, we reset the `input`'s value to an empty string (`""`), so it's ready for the next to-do. |
| 68 | + |
| 69 | +Feel free to change the code or put a few `console.log()` statements in it to see what does what. |
| 70 | + |
| 71 | + |
| 72 | +## Filtering the to-do list |
| 73 | + |
| 74 | +Now, add a dropdown where you can choose to either show all to-dos (like currently), or only those that are not checked. |
| 75 | + |
| 76 | +This can be accomplished in two ways. First the easy way: we add an event listener that gets called when the user changes the dropdown (aka `select` element). Depending on the value of `select.value`, we add or remove the `only-undone` class on the `ul`. Finally, if the `only-undone` class is there, we hide (i.e. `display: none;`) every `li` element that [`has`](https://developer.mozilla.org/en-US/docs/Web/CSS/:has) an `input` that's `:checked`. |
| 77 | + |
| 78 | +```html title=routes/todo-list.html ins={5-10, 23-25, 31-40} |
| 79 | +<!doctype html> |
| 80 | +<title>My To-Do list</title> |
| 81 | + |
| 82 | +<form id="form"> |
| 83 | + <p> |
| 84 | + <select> |
| 85 | + <option value="all">All to-dos</option> |
| 86 | + <option value="undone">Only undone to-dos</option> |
| 87 | + </select> |
| 88 | + </p> |
| 89 | + |
| 90 | + <input placeholder="Add to-do here"> |
| 91 | + <button>+</button> |
| 92 | +</form> |
| 93 | + |
| 94 | +<ul id="todos"> |
| 95 | +</ul> |
| 96 | + |
| 97 | +<style> |
| 98 | + body { |
| 99 | + font-family: sans-serif; |
| 100 | + } |
| 101 | + .only-undone > li:has(input:checked) { |
| 102 | + display: none; |
| 103 | + } |
| 104 | +</style> |
| 105 | + |
| 106 | +<script type="module"> |
| 107 | + const input = document.querySelector("form > input"); |
| 108 | + const todos = document.querySelector("#todos"); |
| 109 | + const select = document.querySelector("select"); |
| 110 | +
|
| 111 | + select.addEventListener("change", () => { |
| 112 | + if (select.value === "undone") { |
| 113 | + todos.classList.add("only-undone"); |
| 114 | + } else { |
| 115 | + todos.classList.remove("only-undone"); |
| 116 | + } |
| 117 | + }) |
| 118 | +
|
| 119 | + document.querySelector("form").addEventListener("submit", (event) => { |
| 120 | + event.preventDefault(); |
| 121 | + const li = document.createElement("li"); |
| 122 | + li.innerHTML = '<input type="checkbox"> '; |
| 123 | + li.append(input.value); |
| 124 | + todos.prepend(li); |
| 125 | + input.value = ""; |
| 126 | + }); |
| 127 | +</script> |
| 128 | +``` |
| 129 | + |
| 130 | +Check it out in your browser's dev tools elements inspector. Notice how the elements with `display: none;` are greyed out, but still there in the DOM tree? |
| 131 | + |
| 132 | +## State |
| 133 | + |
| 134 | +That brings us to the second approach to filtering the to-do list: we could instead remove the elements from the DOM that we currently don't want to show. But then how do we get them back if the user switches the dropdown back to "All to-dos"? We would need to store them in a JavaScript variable, perhaps as an array. |
| 135 | + |
| 136 | +But now we have two places where we store our to-dos: in the DOM, and in the JavaScript variable. This may not sound so bad at first, but developers all over the world have learned the hard way that this is a recipe for pain and bugs. To make sure the two are always in sync, whenever we add, remove, or change a to-do, we would need to remember to do so in two places. Add a few more mutable elements to the app, and you have an expontentially rising number of states to consider. |
| 137 | + |
| 138 | +Information of previous user interactions or events, that still hangs around, is known as the _state_ of the program. It's the source of countless bugs, and the reason why turning a machine off and on again, thereby resetting its state, fixes more problems that we'd like to believe. When programming an interactive app, state is unavoidable. A user changes a dropdown – the state of the program is changed. However, what we can choose, is how we model our state. And duplicating it (e.g. once in the DOM and once in a JavaScript variable), is generally a bad idea. |
| 139 | + |
| 140 | + |
| 141 | +## Reactive programming |
| 142 | + |
| 143 | +The solution to this problem that React.js popularized is that you separate the state out from the rest of the program in a special kind of variable. You then write a so-called `render` function that takes the state as input, and returns what the HTML/DOM should look like. When a user changes a dropdown (or another event happens), you do _not_ update the DOM directly. Instead, you update the state. And on each state change, the framework automatically reruns your render function and updates the DOM. That way, the state and DOM are guaranteed to always be in sync. For a longer introduction to this approach of state management, see for example [Solid's docs](https://docs.solidjs.com/guides/state-management) (a more modern alternative to React). |
| 144 | + |
| 145 | +Mastro comes with its own minimal take on a client-side rendering library: [Reactive Mastro](https://github.com/mastrojs/mastro/tree/main/src/reactive). Like many other reactive libraries (but unlike React), it uses _signals_ to hold state. |
| 146 | + |
| 147 | +To avoid having to add ids or classes, and then look for the elements with `querySelector`, we use [custom elements](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements) – a part of the [web components](https://developer.mozilla.org/en-US/docs/Web/API/Web_components) suite of technologies built into browsers. |
| 148 | + |
| 149 | +The initial to-do list app from above, rewritten with Reactive Mastro looks as follows: |
| 150 | + |
| 151 | +```html title=routes/todo-list.html |
| 152 | +<!doctype html> |
| 153 | +<title>My To-Do list</title> |
| 154 | +<script type="importmap"> |
| 155 | + { |
| 156 | + "imports": { |
| 157 | + "mastro/reactive": "https://esm.sh/mastro@0.0.6/reactive?bundle" |
| 158 | + } |
| 159 | + } |
| 160 | +</script> |
| 161 | + |
| 162 | +<todo-list> |
| 163 | + <form data-onsubmit="addTodo"> |
| 164 | + <input |
| 165 | + placeholder="Enter new to-do here" |
| 166 | + data-bind="value=newTitle" |
| 167 | + data-oninput="updateNewTitle" |
| 168 | + > |
| 169 | + <button>+</button> |
| 170 | + </form> |
| 171 | + <ul data-bind="renderedTodos"> |
| 172 | + </ul> |
| 173 | +</todo-list> |
| 174 | + |
| 175 | +<script type="module"> |
| 176 | + import { computed, html, ReactiveElement, signal } from "mastro/reactive"; |
| 177 | + customElements.define("todo-list", class extends ReactiveElement { |
| 178 | + newTitle = signal(""); |
| 179 | + todos = signal([]); |
| 180 | +
|
| 181 | + renderedTodos = computed(() => |
| 182 | + this.todos().map((todo, i) => html` |
| 183 | + <li> |
| 184 | + <input |
| 185 | + type="checkbox" |
| 186 | + ${todo.done ? "checked" : ""} |
| 187 | + data-onchange='toggleTodo(${i})' |
| 188 | + > |
| 189 | + ${todo.title} |
| 190 | + </li> |
| 191 | + `) |
| 192 | + ); |
| 193 | +
|
| 194 | + toggleTodo (i, e) { |
| 195 | + const todos = [...this.todos()]; |
| 196 | + todos[i].done = e.target.checked; |
| 197 | + this.todos.set(todos); |
| 198 | + } |
| 199 | +
|
| 200 | + updateNewTitle (e) { |
| 201 | + this.newTitle.set(e.target.value); |
| 202 | + } |
| 203 | +
|
| 204 | + addTodo (e) { |
| 205 | + e.preventDefault(); |
| 206 | + if (this.newTitle()) { |
| 207 | + this.todos.set([ |
| 208 | + { title: this.newTitle(), done: false }, |
| 209 | + ...this.todos(), |
| 210 | + ]); |
| 211 | + this.newTitle.set(""); |
| 212 | + } |
| 213 | + } |
| 214 | + }); |
| 215 | +</script> |
| 216 | +``` |
| 217 | + |
| 218 | +At first, this looks more complex. And for simple cases that's true, there you might be better off just using plain JavaScript without any library – especially when coupled with a few nifty lines of CSS. But as your app grows, the initial increase in complexity is quickly outweighed by the structure the library brings, allowing you to not repeatedly write `document.createElement()`, `.append()`, `.addEventListener()`, etc. |
| 219 | + |
| 220 | +The first thing you notice is the `<script type="importmap">`. That [import map](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script/type/importmap) allows you to write `import { ... } from "mastro/reactive"` in your JavaScript modules instead of the full URL. And when it's time to update the URL (perhaps because you want to update to a new version of the library), you just need to do so in one place. |
| 221 | + |
| 222 | +`customElements.define('todo-list', myClass)` registers the `<todo-list>` custom HTML element (the name must start with a lowercase letter and must contain a hyphen), which allows you to use it with `<todo-list></todo-list>` wherever in your HTML. |
| 223 | + |
| 224 | +The `customElements.define` method requires us to supply it with a [class](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes). This is a common concept in _object-oriented programming_, a programming paradigm usually contrasted with _functional programming_ (which we've been losely adhering to in this guide so far). However, you don't need to understand its intricacies to use Reactive Mastro. Just note that the `newTitle`, `todos` and `renderedTodos` variables are declared at the top of the class, and without `const`. That's because they are _fields_ of the class, and accessible with e.g. `this.newTitle` within methods of the class. `toggleTodo`, `updateNewTitle`, and `addTodo` are methods of the class. Methods are functions that are attached to an object or class. |
| 225 | + |
| 226 | +Our fields are all signals. A signal `todos` is read out with `todos()` (a function call), and changed with `todos.set(newArray)`. We initialize the `newTitle`, which represents what's currently in the text `input`, with an empty string. The `todos` is initialized with an empty array. And `renderedTodos` is set to a `computed` value, which always reacts to changes to any of the signals used within it (like `this.todos()`), returning an array of `html` strings – one for each todo. |
| 227 | + |
| 228 | +Finally, our class `extends` the `ReactiveElement` class, which we imported from `mastro/reactive`. This allows us to |
| 229 | + |
| 230 | +- use the `data-bind` attribute in our HTML to bind fields to the DOM elements, so that they are automatically updated whenever the signal changes, and |
| 231 | +- use `data-on*` attributes (e.g. `data-onsubmit`) to cause our methods to be called on events (this also uses `addEventListener`s behind the scenes). |
| 232 | + |
| 233 | + |
| 234 | +## Filtering the to-do list reactively |
| 235 | + |
| 236 | +Once you've gotten familiar with the way Reactive Mastro works, adding the dropdown to filter out the done to-dos, and remove them fron the DOM, is pretty straightforward: |
| 237 | + |
| 238 | +```html title=routes/todo-list.html ins={13-18,34,39-41,63-66} del={38} |
| 239 | +<!doctype html> |
| 240 | +<title>My To-Do list</title> |
| 241 | +<script type="importmap"> |
| 242 | + { |
| 243 | + "imports": { |
| 244 | + "mastro/reactive": "https://esm.sh/mastro@0.0.6/reactive?bundle" |
| 245 | + } |
| 246 | + } |
| 247 | +</script> |
| 248 | + |
| 249 | +<todo-list> |
| 250 | + <form data-onsubmit="addTodo"> |
| 251 | + <p> |
| 252 | + <select data-onchange="updateDropdown"> |
| 253 | + <option value="all">All to-dos</option> |
| 254 | + <option value="undone">Only undone to-dos</option> |
| 255 | + </select> |
| 256 | + </p> |
| 257 | + <input |
| 258 | + placeholder="Enter new to-do here" |
| 259 | + data-bind="value=newTitle" |
| 260 | + data-oninput="updateNewTitle" |
| 261 | + > |
| 262 | + <button>+</button> |
| 263 | + </form> |
| 264 | + <ul data-bind="renderedTodos"> |
| 265 | + </ul> |
| 266 | +</todo-list> |
| 267 | + |
| 268 | +<script type="module"> |
| 269 | + import { computed, html, ReactiveElement, signal } from "mastro/reactive"; |
| 270 | + customElements.define("todo-list", class extends ReactiveElement { |
| 271 | + newTitle = signal(""); |
| 272 | + dropdown = signal("all"); |
| 273 | + todos = signal([]); |
| 274 | +
|
| 275 | + renderedTodos = computed(() => |
| 276 | + this.todos().map((todo, i) => html` |
| 277 | + this.todos() |
| 278 | + .filter((todo) => this.dropdown() === "all" || !todo.done) |
| 279 | + .map((todo, i) => html` |
| 280 | + <li> |
| 281 | + <input |
| 282 | + type="checkbox" |
| 283 | + ${todo.done ? "checked" : ""} |
| 284 | + data-onchange='toggleTodo(${i})' |
| 285 | + > |
| 286 | + ${todo.title} |
| 287 | + </li> |
| 288 | + `) |
| 289 | + ); |
| 290 | +
|
| 291 | + toggleTodo (i, e) { |
| 292 | + const todos = [...this.todos()]; |
| 293 | + todos[i].done = e.target.checked; |
| 294 | + this.todos.set(todos); |
| 295 | + } |
| 296 | +
|
| 297 | + updateNewTitle (e) { |
| 298 | + this.newTitle.set(e.target.value); |
| 299 | + } |
| 300 | +
|
| 301 | + updateDropdown (e) { |
| 302 | + this.dropdown.set(e.target.value); |
| 303 | + } |
| 304 | +
|
| 305 | + addTodo (e) { |
| 306 | + e.preventDefault(); |
| 307 | + if (this.newTitle()) { |
| 308 | + this.todos.set([ |
| 309 | + { title: this.newTitle(), done: false }, |
| 310 | + ...this.todos(), |
| 311 | + ]); |
| 312 | + this.newTitle.set(""); |
| 313 | + } |
| 314 | + } |
| 315 | + }); |
| 316 | +</script> |
| 317 | +``` |
| 318 | +
|
| 319 | +<!-- |
| 320 | +And that's not even considering what happens when a user reloads the page: all the to-dos are gone! So eventually we'd better save them to [localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage#examples) |
| 321 | +--> |
0 commit comments