4 Block structures

This chapter covers

There are three common approaches to adding conditional and iteration logic in the markup of various web frameworks. React uses JSX ( JavaScript XML) where logic is implemented by JavaScript code in curly braces (https://reactjs.org/docs/ introducing-jsx.html). Angular and Vue support framework-specific attributes for logic. For example, Angular supports ngIf and ngFor, while Vue supports v-if and v-for. Svelte supports a Mustache-like (https://mustache.github.io/) custom syntax that wraps HTML and the components to render.

There are only three kinds of block structures in Svelte:

Each of these defines blocks of HTML to render. They begin with {#name}, end with {/name}, and can contain {:name} intermediate markers. The # character indicates a block opening tag. The / character indicates a block ending tag. The : character indicates a block continuation tag. Read on to learn how to use these block structures.

4.1 Conditional logic with {#if}

Conditional logic in the HTML section of a Svelte component begins with {#if condition}, where the condition is any valid JavaScript expression. The end is marked with {/if}. The markup to be conditionally rendered goes between these. Other block tags that can be included between these are {:else if condition} and {:else}.

For example, we can render an assessment of a color as follows:

{#if color === 'yellow'}
  <div>Nice color!</div>
{:else if color === 'orange'}
  <div>That's okay too.</div>
{:else}
  <div>Questionable choice.</div>
{/if}

While this syntax may seem odd at first, it does have the benefit of being able to conditionally render multiple elements without specifying a common parent element.

The Angular/Vue approach of adding special attributes to elements requires specifying a common parent element in order to render multiple elements. In Angular this can be the special <ng-container> element, which does not produce a corresponding DOM element, as in the following listing.

Listing 4.1 Conditional logic in Angular using ng-container

<ng-container *ngIf="isMorning">
  <h1>Good Morning!</h1>
  <p>There is a lot on your plate today.</p>
</ng-container>

In Vue the common parent element can be a div element, but that element will appear in the rendered output, as in the following listing.

Listing 4.2 Conditional logic in Vue using a div

<div v-if="isMorning">
  <h1>Good Morning!</h1>
  <p>There is a lot on your plate today.</p>
</div>

React requires a common parent element or a React-specific “fragment.” Fragments do not produce a corresponding DOM element.

Listing 4.3 Conditional logic in React using a fragment

{isMorning && (
  <>
    <h1>Good Morning!</h1>
    <p>There is a lot on your plate today.</p>
  </>
)}

Svelte does not require any wrapping element to conditionally render multiple elements. Its block syntax is used instead.

Listing 4.4 Conditional logic in Svelte

{#if isMorning}
  <h1>Good Morning!</h1>
  <p>There is a lot on your plate today.</p>

4.2 Iteration with {#each}

Iteration in HTML begins with {#each array as item}. The end is marked with {/each}. The markup to be rendered for each item goes between these.

The expression that follows #each can be any JavaScript expression that results in an array or array-like object. This includes literal values, variables, and function calls.

Optionally {:else} can be used before {/each}. Content after {:else} is rendered when the array is empty.

For example, suppose the variable colors is set to ['red', 'green', 'blue']. The following example outputs each color on a separate line using the color.

{#each colors as color}
  <div style="color: {color}">{color}</div>
{/each}

The next example outputs each color on a separate line preceded by its 1-based position followed by a parenthesis:

{#each colors as color, index}
  <div>{index + 1}) {color}</div>
{/each}

The following example uses destructuring to get specific properties of the objects in the array people:

{#each people as {name, age}}
  <div>{name} is {age} years old.</div>
{:else}
  <div>There are no people.</div>
{/each}

Another option is to iterate over the keys and values of an object using Object.entries as follows:

<script>
  const person = {
    color: 'yellow',
    name: 'Mark',
    spouse: {
      color: 'blue',
      name: 'Tami'
    }
  };
</script>
 
{#each Object.entries(person) as [key, value]}
  <div>found key "{key}" with value {JSON.stringify(value)}</div>
{/each}

This renders the following:

found key "color" with value "yellow"
found key "name" with value "Mark"
found key "spouse" with value {"color":"blue","name":"Tami"}

If the items in an array will be added, removed, or modified after they are initially rendered, a unique identifier should be supplied for each element. Svelte refers to this as a “keyed each block,” and it allows Svelte to optimize updating the DOM. It is similar to the need for the key prop in React and Vue.

In Svelte, the unique identifier is supplied as part of the #each syntax, not as a prop. In the following example, the unique identifier for each person is their id property.

{#each people as person (person.id)}
  <div>{person.name} is {person.age} years old.</div>
{/each}

To iterate a given number of times rather than over all the elements in an array, you can create an array with that number of elements. For example,

{#each Array(rows) as _, index}      
  <div>line #{index + 1}</div>
{/each}

Array(rows) creates an array of length rows where all the elements are undefined.

4.3 Promises with {#await}

Svelte provides a block structure to wait for promises to resolve or reject. It can render different output based on whether the promise is still pending, has resolved, or has rejected.

For example, suppose we have a getDogs function that calls an API service, perhaps using the Fetch API, to retrieve a list of dog descriptions. Calling an API service is an asynchronous operation, so a JavaScript Promise object is returned. It will resolve to an array of objects that describe dogs. Each has a name and breed property.

Note Using the Fetch API is described in appendix B.

Any variable name can be used after :then and :catch to receive the resolved or rejected value.

{#await getDogs()}
  <div>Waiting for dogs ...</div>
{:then dogs}
  {#each dogs as dog}
    <div>{dog.name} is a {dog.breed}.</div>
  {/each}
{:catch error}
  <div class="error">Error: {error.message}</div>
{/await}

The next example omits the markup to be rendered while waiting for the Promise to resolve. The :catch portion can also be omitted, but it’s typically best to render something to let the user know if the promise was rejected.

{#await getDogs() then dogs}
  {#each dogs as dog}
    <div>{dog.name} is a {dog.breed}.</div>
  {/each}
{:catch error}
  <div class="error">Error: {error.message}</div>
{/await}

If a component needs to trigger re-evaluating data from a promise, you can store the promise in a top-level variable, use that variable after #await (eg., {#await myPromise}), and modify the variable in a function.

Figure 4.1 Dog breed image app

Let’s demonstrate this using a publicly available API service that returns images of a given dog breed (see figure 4.1). Copy this code to a REPL and try it!

Listing 4.5 Dog breed image app

<script>
  let breed = 'Whippet';                             

  async function getDogs() {                         
    const url =
      'https://dog.ceo/api/breed/' +
      `${breed.toLowerCase()}/images/random/1`;
    const res = await fetch(url);
    if (!res.ok || res.status === 404) return [];
    const json = await res.json();
    return json.message;
  }

  let dogsPromise = getDogs();
</script>

<label>
  Breed
  <input bind:value={breed}>
</label>
<button on:click={() => dogsPromise = getDogs()}>    
  Get Image
</button>

{#await dogsPromise}
  <div>Waiting for dogs ...</div>
{:then imageUrls}
  {#each imageUrls as url}
    <div><img alt="dog" src={url}></div>
  {:else}
    <div>Not found</div>
  {/each}
{:catch error}
  <div>Error: {error.message}</div>
{/await}

A Whippet is a dog breed.

This returns a promise.

Changing the value of dogsPromise causes the #await to be evaluated again.

Listing 4.6 shows one more example of using #await that uses a publicly available API service intended for demonstration purposes, and the results are shown in figure 4.2. The service returns a JSON object containing two properties, status and data. The status property is set to the string “success” when the call is successful. The data property holds an array of objects describing employees at a company.

Figure 4.2 Employee table

Listing 4.6 Component that renders the employee table

<script>
  let employees = [];
  let message;

  async function getEmployees() {
    const res = await fetch(
      'http://dummy.restapiexample.com/api/v1/employees');
    const json = await res.json();
    if (json.status === 'success') {
      return json.data.sort(                                             
        (e1, e2) => e1.employee_name.localeCompare(e2.employee_name));
    } else {
      throw new Error(json.status);
    }
  }
</script>

{#await getEmployees()}
  <div>Loading employees ...</div>
{:then employees}
  <table>
    <caption>Employees</caption>
    <tr><th>Name</th><th>Age</th></tr>
    {#each employees as employee}
      <tr>
        <td>{employee.employee_name}</td>
        <td>{employee.employee_age}</td>
      </tr>
    {/each}
  </table>
{:catch message}
  <div class="error">Failed to retrieve employees: {message}</div>
{/await}

<style>
  caption {
    font-size: 1rem;
    font-weight: bold;
    margin-bottom: 0.5rem;
  }
  .error {
    color: red;
  }
  table {
    border-collapse: collapse;
  }
  td, th {
    border: solid lightgray 1px;
    padding: 0.5rem;
  }

This sorts the employees on their name, first before last.

4.4 Building the Travel Packing app

Let’s apply what you have learned about Svelte block structures to the Travel Packing app. The finished code can be found at http://mng.bz/vxBM.

We need to walk through a lot of code here, but it should all be understandable given what you have learned so far. This will set the stage for adding more features to the app in subsequent chapters.

The Item component uses #if to render the item name in an HTML input when in editing mode and in an HTML span otherwise. The Category component does the same for its name. It also uses #each to iterate over the items in a category. The Checklist component uses #each to iterate over the categories in a checklist.

Eventually we will create all the files shown in figure 4.3. The arrows point to files that are imported by a given file.

Figure 4.3 Travel Packing app source files

4.4.1 Item component

First we need a component to represent an item to be packed. Items belong to a category, and categories have an ID. The Item component takes one prop, item, whose value is an object. An item object has the properties id (string), name (string), and packed (Boolean).

An item is represented by an li element that contains three children:

Figure 4.4 Item components

When an item is packed, its name is displayed in gray and with line-through, as shown in figure 4.4.

Listing 4.7 Item component defined in src/Item.svelte

<script>
  import {blurOnKey} from './util';

  export let item;
 
  let editing = false;
</script>
 
<li>
  <input type="checkbox" bind:checked={item.packed}>
  {#if editing}
    <input
      autofocus
      bind:value={item.name}
      on:blur={() => (editing = false)}
      on:keydown={blurOnKey}
      type="text" />
  {:else}
    <span class="packed-{item.packed}" on:click={() => (editing = true)}>
      {item.name}
    </span>
  {/if}
  <button class="icon">&#x1F5D1;</button>
</li>
 
<style>
  button {
    background-color: transparent;
    border: none;
  }
 
  input[type='checkbox'] {
    --size: 24px;                 
    height: var(--size);
    width: var(--size);
  }
 
  input[type='text'] {
    border: solid lightgray 1px;
  }
 
  li {
    display: flex;
    align-items: center;
  }
 
  .packed-true {
    color: gray;
    text-decoration: line-through;
  }
 
  span {
    margin: 0 10px;
  }
</style>

This is a CSS variable.

4.4.2 Utility functions

Some components use functions defined in util.js, shown in listing 4.8.

The getGuid function returns a unique ID. This is used to assign IDs to items to be packed. It requires the uuid npm package, which you can install by entering npm install uuid.

The sortOnName function is used to sort items within a category in a case-insensitive way.

Listing 4.8 Utility functions defined in src/util.js

import {v4 as uuidv4} from 'uuid';
 
export function getGuid() {
  return uuidv4();
}
 
export function blurOnKey(event) {
  const {code} = event;
  if (code === 'Enter' || code === 'Escape' || code === 'Tab') {
    event.target.blur();
  }
}
 
export function sortOnName(array) {
  array.sort((el1, el2) =>
    el1.name.toLowerCase().localeCompare(el2.name.toLowerCase())
  );
  return array;

4.4.3 Category component

Next we need a component to represent a category of items, such as “Clothing.” The Category component renders the following (see figure 4.5):

Figure 4.5 Category component

A category object has the properties id (string), name (string), and items (array of item objects).

The Category component takes three props: categories (array of category objects), category (object), and show (string). The categories prop is used to determine whether an item to be added already exists in another category. The category prop describes the category being rendered. The show prop is a string with a value of all, packed, or unpacked. It indicates which of the items should be displayed.

Clicking the name of the category changes it to an input so it can be edited. Moving focus out of the input or pressing the Enter key finalizes the change.

Listing 4.9 Category component defined in src/Category.svelte

<script>
  import Item from './Item.svelte';
  import {getGuid, blurOnKey, sortOnName} from './util';
 
  export let categories;
  export let category;
  export let show;
 
  let editing = false;
  let itemName = '';
  let items = [];
  let message = '';
 
  $: items = Object.values(category.items);
  $: remaining = items.filter(item => !item.packed).length;
  $: total = items.length;
  $: status = `${remaining} of ${total} remaining`;
  $: itemsToShow = sortOnName(items.filter(i => shouldShow(show, i)));
 
  function addItem() {
    const duplicate = Object.values(categories).some(cat =>
      Object.values(cat.items).some(item => item.name === itemName)
    );
    if (duplicate) {
      message = `The item "${itemName}" already exists.`;
      alert(message);                                         
      return;
    }
 
    const {items} = category;
    const id = getGuid();
    items[id] = {id, name: itemName, packed: false};
    category.items = items;
    itemName = '';                                            
  }
 
  function shouldShow(show, item) {
    return (
      show === 'all' ||
      (show === 'packed' && item.packed) ||
      (show === 'unpacked' && !item.packed)
    );
  }
</script>
 
<section>
  <h3>
    {#if editing}
      <input
        bind:value={category.name}
        on:blur={() => (editing = false)}
        on:keypress={blurOnKey} />
    {:else}
      <span on:click={() => (editing = true)}>{category.name}</span>
    {/if}
    <span class="status">{status}</span>
    <button class="icon">&#x1F5D1;</button>
  </h3>
 
  <form on:submit|preventDefault={addItem}>
    <label>
      New Item
      <input bind:value={itemName}>
    </label>
    <button disabled={!itemName}>Add Item</button>
  </form>
 
  <ul>
    {#each itemsToShow as item (item.id)}
      <!-- This bind causes the category object to update
        when the item packed value is toggled. -->
      <Item bind:item />                                      
    {:else}
      <div>This category does not contain any items yet.</div>
    {/each}
  </ul>
</section>
 
<style>
  button,
  input {
    border: solid lightgray 1px;
  }
 
  button.icon {
    border: none;
  }
 
  h3 {
    display: flex;
    justify-content: space-between;
    align-items: center;
 
    margin: 0;
  }
 
  section {
    --padding: 10px;
 
    background-color: white;
    border: solid transparent 3px;
    border-radius: var(--padding);
    color: black;
    display: inline-block;
    margin: var(--padding);
    padding: calc(var(--padding) * 2);
    padding-top: var(--padding);
    vertical-align: top;
  }
 
  .status {
    font-size: 18px;
    font-weight: normal;
    margin: 0 15px;
  }
 
  ul {
    list-style: none;
    margin: 0;
    padding-left: 0;
  }
</style>

This will be replaced by a dialog in chapter 7.

This clears the input.

This is equivalent to <Item bind:item={item} />.

4.4.4 Checklist component

Now we need a component to render all the categories. The Checklist component renders the following (see figure 4.6):

Figure 4.6 Checklist component

The Checklist component does not take any props.

Listing 4.10 Checklist component defined in src/Checklist.svelte

<script>
  import Category from './Category.svelte';
  import {getGuid, sortOnName} from './util';
 
  let categoryArray = [];
  let categories = {};
  let categoryName;
  let message = '';
  let show = 'all';
 
  $: categoryArray = sortOnName(Object.values(categories));
 
  function addCategory() {
    const duplicate = Object.values(categories).some(
      cat => cat.name === categoryName
    );
    if (duplicate) {
      message = `The category "${categoryName}" already exists.`;
      alert(message);                                                
      return;
    }
 
    const id = getGuid();
    categories[id] = {id, name: categoryName, items: {}};
    categories = categories;                                         
    categoryName = '';                                               
  }
 
  function clearAllChecks() {                                        
    for (const category of Object.values(categories)) {
      for (const item of Object.values(category.items)) {
        item.packed = false;
      }
    }
    categories = categories;
  }
</script>
 
<section>
  <header>
    <form on:submit|preventDefault={addCategory}>
      <label>
        New Category
        <input bind:value={categoryName}>
      </label>
      <button disabled={!categoryName}>Add Category</button>
      <button class="logout-btn">
        Log Out
      </button>
    </form>
    <p>
      Suggested categories include Backpack, Clothes,
      <br />
      Last Minute, Medicines, Running Gear, and Toiletries.
    </p>
 
    <div class="radios">                                                
      <label>Show</label>
      <label>
        <input name="show" type="radio" value="all" bind:group={show}>  
        All
      </label>
      <label>
        <input name="show" type="radio" value="packed" bind:group={show}>
        Packed
      </label>
      <label>
        <input name="show" type="radio" value="unpacked" bind:group={show}>
        Unpacked
      </label>

      <button class="clear" on:click={clearAllChecks}>Clear All Checks</button>
    </div>
  </header>

  <div class="categories">
    {#each categoryArray as category (category.id)}
      <Category bind:category {categories} {show} />
    {/each}
  </div>
</section>

<style>
  .categories {
    display: inline-flex;
    flex-wrap: wrap;
    justify-content: center;
  }

  .clear {
    margin-left: 30px;
  }
 
  input[type='radio'] {
    --size: 24px;
    height: var(--size);
    width: var(--size);
    margin-left: 10px;
  }

  .logout-btn {
    position: absolute;
    right: 20px;
    top: 20px;
  }
 
  .radios {
    display: flex;
    align-items: center;
  }
 
  .radios > label:not(:first-of-type) {
    display: inline-flex;
    align-items: center;
 
    margin-left: 1em;
  }
 
  .radios > label > input {
    margin-bottom: -3px;
    margin-right: 5px;
  }

  section {
    display: flex;
    flex-direction: column;
    align-items: center;
 
    font-size: 24px;
    margin-top: 1em;
  }
</style>

This will be replaced by a dialog in chapter 7.

This triggers an update.

This clears the input.

Consider adding a confirmation here to avoid accidentally clearing all the checks.

There are accessibility issues here that will be fixed in chapter 12.

Using bind:group with a set of related radio buttons makes the value a single string.

4.4.5 App component

For now we will render this component in the App component instead of the Login component created in the previous chapter. Later we will add logic to render only one of them at a time, depending on the state of the app.

Modify App.svelte:

  1. Comment out the import of the Login component at the top of the script element.

  2. Import the Checklist component at the top of the script element.

    import Checklist from './Checklist.svelte';
  3. In the HTML section, comment out <Login /> and add <Checklist />.

To specify global styling for the app that can affect any component, replace the contents of public/global.css with the following:

Listing 4.11 Global CSS defined in public/global.css

body {
  font-family: sans-serif;
  height: 100vh;
  margin: 0;
  padding: 0;
}

button:not(:disabled),
input:not(:disabled) {
  cursor: pointer;
}

button:disabled {
  color: lightgray;
}

button.icon {
  background-color: transparent;
  border: none;
  margin-bottom: 0;
}

input:disabled {
  color: #ccc;
}

label {
  display: inline-block;
}

input,
button,
select,
textarea {
  --padding: 10px;

  border-radius: var(--padding);
  border: none;
  box-sizing: border-box;
  color: gray;
  font-family: inherit;
  font-size: inherit;
  margin: 0;
  padding: var(--padding);

4.4.6 Try it

Run the app by entering npm run dev and browsing to localhost:5000. You should now see the page shown in figure 4.7.

Figure 4.7 Travel Packing app

Try adding some categories and adding items in each category. Click category and item names to modify them. Check some items to mark them as packed. Click the Show radio buttons to switch between showing all items, only packed items, or only unpacked items.

Clicking the trash can icons will not delete categories and items just yet. We will implement those in chapter 5, and you will also learn about all the ways in which Svelte components can share data.

Summary