Understanding Svelte – Simple event handling and some more reactivity

Understanding Svelte – Simple event handling and some more reactivity

The last time we built a small crypto card (more like a label) that showed the current price of Bitcoin. In this post, we'll expand that to show historical prices. We'd like to end up with something like the following, only with real prices:

The markup for our crypto card looks as follows, with most of the css classes removed to reduce noise (I use Tailwind, a functional CSS library).

<div class="xs:w-full md:w-1/2 lg:w-1/6">
  <div class="main">
    <label>BTC</label>
    <div class="icon-holder">
      <IconRefresh fill="#CBD5E0" stroke="#CBD5E0" strokeWidth="0.1"/>
    </div>
    <span class="text-lg">${formattedPrice}</span>
  </div>
  <div class="icon-holder">
    <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
      <!-- The opener icon -->
      <circle cx="12" cy="12" r="10" stroke="#5A67D8" stroke-width="0.6" fill="#F7FAFC"/>
      <path d="m11 14.59v-7.59a1 1 0 0 1 2 0v7.59l2.3-2.3a1 1 0 1 1 1.4 1.42l-4 4a1 1 0 0 1 -1.4 0l-4-4a1 1 0 0 1 1.4-1.42z" fill="#5A67D8" stroke="#5A67D8" stroke-width="0.1" />
    </svg>
  </div>
  <div class="historical-prices"> 
    <div class="table mx-auto">
      <PriceRow label="1d" priceChange="-7.51" />
      <PriceRow label="1w" priceChange="-5.79" />
      <PriceRow label="1m" priceChange="-0.86" />
      <PriceRow label="3m" priceChange="11.36" />
      <PriceRow label="6m" priceChange="145.42" />
      <PriceRow label="1y" priceChange="45.20" />
    </div>
  </div>
</div>

There's a new component, `PriceRow`, that's responsible for displaying one historical price change. Currently, the values are static. That component looks like that:

// src/PriceRow.svelte
<script>
  import IconArrow from './IconArrow.svelte';

  export let label;
  export let priceChange;
</script>

<style>
  span {
    font-feature-settings: "tnum";
  }
</style>

<div class="table-row">
  <label>{label}</label>
  <span>{priceChange}%</span>
  <div class="table-cell">
    <IconArrow fill="#5A67D8" stroke="#5A67D8" strokeWidth="0.1px" rotation="-45" />
  </div>
</div>

If you want to build the project along with me, here's the source of the IconArrow and IconRefresh components.

The first thing we should fix is that the arrow indicating the price change is wrong. It always points downwards, indicating a price decrease although we do have positive changes for longer term values.

The fix is done through a reactive declaration:

// src/PriceRow.svelte
<script>
  // (...)
  $: hasPriceIncreased = Number(priceChange) > 0;
</script>

<div class="table-row">
  <label>{label}</label>
  <span>{priceChange}%</span>
  <div class="table-cell">
    <IconArrow fill="#5A67D8" stroke="#5A67D8" strokeWidth="0.1px" rotation="{hasPriceIncreased ? -135 : -45}" />
  </div>
</div>

The arrow direction is now fixed:

Our first event handler

Let's now hide the prices until the panel is expanded by clicking the downward pointing arrow icon. We'll do this by implementing  a state flag that we name isExpanded:

<script>
  // (...)
  let isExpanded = isFalse;
  function toggleExpanded() {
    isExpanded = !isExpanded;
  }
</script>
<!-- (...) -->
  <div class="icon-holder">
    <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" on:click={toggleExpanded}>
      <circle cx="12" cy="12" r="10" stroke="#5A67D8" stroke-width="0.6" fill="#F7FAFC"/>
      {#if isExpanded}
        <path d="m13 9.41v7.59a1 1 0 0 1 -2 0v-7.59l-2.3 2.3a1 1 0 1 1 -1.4-1.42l4-4a1 1 0 0 1 1.4 0l4 4a1 1 0 0 1 -1.4 1.42l-2.3-2.31z" fill="#5A67D8" stroke="#5A67D8" stroke-width="0.1"/>
      {:else}
        <path d="m11 14.59v-7.59a1 1 0 0 1 2 0v7.59l2.3-2.3a1 1 0 1 1 1.4 1.42l-4 4a1 1 0 0 1 -1.4 0l-4-4a1 1 0 0 1 1.4-1.42z" fill="#5A67D8" stroke="#5A67D8" stroke-width="0.1" />
      {/if}
    </svg>
  </div>
  {#if isExpanded}
    <div class="historical-prices" >
      <div class="table">
        <PriceRow label="1d" priceChange="-7.51" />
        <PriceRow label="1w" priceChange="-5.79" />
        <PriceRow label="1m" priceChange="-0.86" />
        <PriceRow label="3m" priceChange="11.36" />
        <PriceRow label="6m" priceChange="145.42" />
        <PriceRow label="1y" priceChange="45.20" />
      </div>
    </div>
  {/if}

The interesting stuff happens on the svg. on:click adds an event listener that will be triggered on click events. If we wanted to trigger it on hover, we'd write on:hover.

(Note how it was enough to wrap the one path element in the conditional – it's all just html.)

That works: the historical prices are hidden by default and get shown when the user expands the panel. We'll now code towards having the price change calculated by the PriceRow component and then fetching and displaying real values in the historical prices panel.

Reactive assignments – again

If we want PriceRow to calculate the price change, we have to pass in the historical price and the current price:

<div class="historical-prices" >
  <div class="table">
    <PriceRow label="1d" price="10342.07" currentPrice={price} />
    <PriceRow label="1w" price="10283.19" currentPrice={price} />
    <PriceRow label="1m" price="10241.35" currentPrice={price} />
    <PriceRow label="3m" price="10597.86" currentPrice={price} />
    <PriceRow label="6m" price="8379.70" currentPrice={price} />
    <PriceRow label="1y" price="6555.33" currentPrice={price} />
  </div>
</div>

We can now calculate the price change "locally", in the PriceRow component that displays it:

<script>
  export let currentPrice;
  export let price;

  function priceChange() {
    let p = parseFloat(price);
    return ((currentPrice - p) / p) * 100;
  }

  $: priceChange = round(priceChange());
</script>

<div class="table-row">
  <label>{label}</label>
  <span>{priceChange}%</span>
  <div class="table-cell">
    <IconArrow fill="#5A67D8" stroke="#5A67D8" strokeWidth="0.1px" rotation="{hasPriceIncreased ? -135 : -45}" />
  </div>
</div>

That will work if the passed-in price and currentPrice never changes but there's a bug in the declaration of priceChange (and if you've read the previous blog post in the series, you might already know what's wrong). Its dependencies cannot be inferred so Svelte doesn't know what it should react upon – when should priceChange be re-calculated.

It should be defined in a way so that the dependencies are explicit in the definition:

function priceChange(current, old) {
  let oldPrice = parseFloat(old);
  return ((current - oldPrice) / oldPrice) * 100;
}

$: priceChange = round(priceChange(currentPrice, price));

Let's see if we got this right by fetching the real historical prices via a network request.

Showing real prices

We need to do some restructuring in CryptoCard to be able to fetch and hold several prices and pass them to the PriceRows for display:

<script>
  import { format, subDays } from 'date-fns';
  
  let currentPrice = null;
  let prices = {};
  let labelMapping = {
    '1d': 1,
    '1w': 7,
    '1m': 30,
    '3m': 90,
    '6m': 182,
    '1y': 365,
  };
  
  async function updateCurrentPrice() {
    isLoadingPrice = true;
    let priceInfo =	await fetch(`https://api.coinbase.com/v2/prices/BTC-USD/spot`).then(r => r.json());
    isLoadingPrice = false;
    currentPrice = priceInfo.data.amount;
  }

  function hideHistoricalPrices() {
    isExpanded = false;
  }

  async function showHistoricalPrices() {
    isExpanded = true;
    for (let label in labelMapping) {
      let days = labelMapping[label];
      let date = format(subDays(new Date(), days), 'yyyy-MM-dd');
      let priceInfo = await fetch(`https://api.coinbase.com/v2/prices/BTC-USD/spot?date=${date}`).then(r => r.json());
      prices[label] = priceInfo.data.amount;
    }
  }
  // (...)
</script>

<div>
  <div class="icon-holder mx-auto relative dented">
    <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" on:click={isExpanded ? hideHistoricalPrices : showHistoricalPrices}>
      <circle cx="12" cy="12" r="10" stroke="#5A67D8" stroke-width="0.6" fill="#F7FAFC"/>
      {#if isExpanded}
        <path d="m13 9.41v7.59a1 1 0 0 1 -2 0v-7.59l-2.3 2.3a1 1 0 1 1 -1.4-1.42l4-4a1 1 0 0 1 1.4 0l4 4a1 1 0 0 1 -1.4 1.42l-2.3-2.31z" fill="#5A67D8" stroke="#5A67D8" stroke-width="0.1"/>
      {:else}
        <path d="m11 14.59v-7.59a1 1 0 0 1 2 0v7.59l2.3-2.3a1 1 0 1 1 1.4 1.42l-4 4a1 1 0 0 1 -1.4 0l-4-4a1 1 0 0 1 1.4-1.42z" fill="#5A67D8" stroke="#5A67D8" stroke-width="0.1" />
      {/if}
    </svg>
  </div>
</div>

We store the historical price values in prices, keyed by label (`1d`, 6m). We split apart showing and hiding the price panel as we fetch the historical prices only when the panel is opened. Finally, let's see the bit that displays them:

<div>
  <!-- (...) -->
  {#if isExpanded}
    <div class="historical-prices">
      <div class="table">
        {#each Object.keys(prices) as label}
          <PriceRow {label} {currentPrice} price={prices[label]} />
        {/each}
      </div>
    </div>
  {/if}
</div>    

There are two new things in this snippet.

The first is that #each can take any JavaScript expression that returns an iteratable. Here, we loop through the keys of the prices object, the time labels. The second is a shorthand. Instead of writing `label={label}`, we can use the {label} shorthand which expands to the same. This is effectively JavaScript's Enhanced Object Literals in Svelte markup.

Our historical price panel is now fully(?) functional:

The working historical prices panel
We should've bought BTC 6 months ago

Next up

One thing we should definitely improve is that due to the await in showHistoricalPrices the expansion of the historical prices panel is blocked until all historical prices have been fetched. That might result in a bad user experience if the network requests have some delay.

So we'll improve this part of our app next.