Understanding Svelte – Indicating loading state
In the last post of building the crypto card series (aka. "Understanding Svelte"), we tuned the async bits and added some animation to improve user feedback. We'll now continue down that path and indicate to the user while data (bitcoin prices) are being fetched. We'll also allow refreshing prices by clicking the same icon.
The relevant bits of our CryptoCard component look as follows:
<!-- src/CryptoCard.svelte -->
<script>
// (...)
async function updateCurrentPrice() {
let priceInfo = await fetch(`https://api.coinbase.com/v2/prices/${crypto}-USD/spot`).then(r => r.json());
currentPrice = priceInfo.data.amount;
}
function showHistoricalPrices() {
isExpanded = true;
for (let label in labelMapping) {
let days = labelMapping[label];
let date = format(subDays(new Date(), days), 'yyyy-MM-dd');
let promise = fetch(`https://api.coinbase.com/v2/prices/BTC-USD/spot?date=${date}`).then(r => r.json());
prices[label] = promise.then(p => p.data.amount);
}
}
function hideHistoricalPrices() {
isExpanded = false;
}
onMount(updateCurrentPrice);
</script>
<!-- ... -->
<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>
{#if isExpanded}
<div class="historical-prices p-4 border border-t-0 border-indigo-600">
<div class="table table-fixed w-full">
{#each Object.keys(prices) as label}
<PriceRow label={label} pricePromise={prices[label]} {currentPrice} />
{/each}
</div>
</div>
{/if}
As this currently stands, the current price is fetched when the component is rendered thanks to updateCurrentPrice
being called from onMount
. Earlier prices are fetched when the card is expanded. However, there's no indication that data fetching is happening.
Indicating fetching the current price
It'd look really slick if the refresh icon was rotating while data was being fetched. To do this, we'll pass a rotation transform to the IconRefresh
component and update it at regular intervals while the network request is ongoing.
We'll need a few functions that updates the angle the icon is rotated, and stops the "animation" when the network request has finished.
<script>
let rotation = 0;
let rotationTimeout;
function showPriceBeingFetched() {
rotateLoadingIcon();
rotationTimeout = setTimeout(showPriceBeingFetched, 50);
}
function priceFetched() {
stopRotatingLoadingIcon();
clearTimeout(rotationTimeout);
}
function rotateLoadingIcon() {
rotation = (rotation + 18) % 180;
}
function stopRotatingLoadingIcon() {
rotation = 0;
}
// ...
</script>
Every 50 milliseconds, we'll rotate by 18 degrees. We start the rotation when we fetch the price, and stop it when it's returned:
// ...
async function updateCurrentPrice() {
showPriceBeingFetched();
let priceInfo = await fetch(`https://api.coinbase.com/v2/prices/${crypto}-USD/spot`)
.then(r => r.json());
priceFetched();
currentPrice = priceInfo.data.amount;
}
We can now pass the rotation
variable to the IconRefresh
component:
<div class="xs:w-full md:w-1/2 lg:w-1/6" class:md:ml-4={index > 0}>
<div class="flex flex-row h-16 p-4 py-0 items-center text-gray-100 bg-indigo-600 justify-start">
<label class="text-2xl">{crypto}</label>
<div class="icon-holder">
<IconRefresh fill="#CBD5E0" stroke="#CBD5E0" strokeWidth="0.1" {rotation} />
</div>
<span class="text-lg">{formattedPrice}</span>
</div>
<!-- ... -->
Since fetching data from Coinbase is very quick, we risk not seeing the icon spin even if we get the implementation right. We need a mechanism to make it last a few seconds.
One idea is to simulate a slow network connection with Chrome Developer Tools. The problem, however, is that this will load all assets very slowly, and in testing, loading the CSS bundle proved a lot slower: by the time it was loaded, the network request had finished.
Let's instead add a simple utility function that only delays the fetch:
function delayedFetch(url, delay) {
return new Promise(resolve => {
setTimeout(() => {
resolve(fetch(url));
}, delay);
});
}
and replace the fetch
with delayedFetch
when fetching the price:
async function updateCurrentPrice() {
showPriceBeingFetched();
let priceInfo = await delayedFetch(`https://api.coinbase.com/v2/prices/${crypto}-USD/spot`, 5000)
.then(r => r.json());
priceFetched();
currentPrice = priceInfo.data.amount;
}
It all works great now, the icon is spinning beautifully while the price of Bitcoin is being fetched:
No reactive assignments?
You might notice that the rotation variable is declared simply in JavaScript, with a let
and yet whenever its value is updated, it correctly updates in the template and is passed on to IconRefresh
– that's why we see the icon rotate.
I was surprised by this as I thought I'd have to use the reactive assignment, $: rotation = 0
for it to work. I then realized it works because rotation
is not derived, but root state: every time its value changes, Svelte re-renders the template bits its used in. Since it's an argument to the IconRefresh
call, it'll also be rendered again.
An example of where the reactive assignment is needed is formattedPrice
in this same component. If you need a refresher, you can read one of the previous blog posts in this series, Understanding Svelte - Reactivity (Part 1) where I explain reactive assignments in detail.
Fetching historical prices
We want the icon to spin also while fetching historical prices. Currently, the showHistoricalPrices
looks as follows:
function showHistoricalPrices() {
isExpanded = true;
for (let label in labelMapping) {
let days = labelMapping[label];
let date = format(subDays(new Date(), days), 'yyyy-MM-dd');
let promise = fetch(`https://api.coinbase.com/v2/prices/${crypto}-USD/spot?date=${date}`).then(r => r.json());
prices[label] = promise.then(p => p.data.amount);
}
}
Just as in the case of the current price, we have to manually call showPriceBeingFetched
when we start fetching prices and priceFetched
when all prices have been fetched. (We should probably rename those functions to better describe they are also used when fetching multiple prices).
function showHistoricalPrices() {
showPriceBeingFetched();
isExpanded = true;
for (let label in labelMapping) {
let days = labelMapping[label];
let date = format(subDays(new Date(), days), 'yyyy-MM-dd');
let promise = delayedFetch(`https://api.coinbase.com/v2/prices/${crypto}-USD/spot?date=${date}`, 3000).then(r => r.json());
prices[label] = promise.then(p => p.data.amount);
}
Promise.all(Object.values(prices)).then(() => {
priceFetched();
});
}
Since the values in prices
are promises, one for each label ("1d", "1w", "1m", and so on), we know that the moment all prices have been fetched is when all promises have been resolved. That's exactly what Promise.all
does.
Et voilà, it works:
Click to refresh
To round up this feature, let's allow the user to click on the refresh icon to re-fetch prices. If the panel is collapsed, we should only refetch the current prices but if it's expanded, all of them.
Currently, when the user clicks on the down arrow to see historical prices, the showHistoricalPrices
method (see above) does two things: it reveals the panel and it fetches those prices. We want to change this so that that action only expands the panel: re-fetching prices should only happen if the user explicitly asks for it by clicking the refresh icon.
We first modify the template to call the right method for the refresh icon and the arrow that expands or collapses the historical prices panel:
<div class="flex flex-row h-16 p-4 py-0 items-center text-gray-100 bg-indigo-600 justify-start">
<label class="text-2xl">{crypto}</label>
<button class="icon-holder" on:click={refetchPrices}>
<IconRefresh fill="#CBD5E0" stroke="#CBD5E0" strokeWidth="0.1" {rotation} />
</button>
<span class="text-lg">{formattedPrice}</span>
</div>
<div class="icon-holder mx-auto relative dented">
<svg
viewBox="0 0 24 24"
on:click={toggleHistoricalPrices}>
<!-- (...) -->
</svg>
</div>
Let's also add minor styling to the "icon-holder" element since it's now a button:
<style>
.icon-holder {
border: 0;
outline: none;
/* ... */
}
</style>
We'll need to make toggleHistoricalPrices
toggle the prices panel and fetch historical prices as needed. refetchPrices
should query the prices again:
function toggleHistoricalPrices() {
if (isExpanded) {
isExpanded = false;
} else {
isExpanded = true;
fetchHistoricalPrices();
}
}
function fetchHistoricalPrices() {
showPriceBeingFetched();
for (let label in labelMapping) {
let days = labelMapping[label];
let date = format(subDays(new Date(), days), 'yyyy-MM-dd');
let promise = delayedFetch(`https://api.coinbase.com/v2/prices/${crypto}-USD/spot?date=${date}`, 3000).then(r => r.json());
prices[label] = promise.then(p => p.data.amount);
}
Promise.all(Object.values(prices)).then(() => {
priceFetched();
});
}
async function refetchPrices() {
await updateCurrentPrice();
if (isExpanded) {
fetchHistoricalPrices();
}
}
To save on bandwidth (and throttle our usage of the Coinbase API), let's only fetch historical prices on the first fetch or if the user explicitly asks for it:
let historicalPricesFetched = false;
// (...)
function toggleHistoricalPrices() {
if (isExpanded) {
isExpanded = false;
} else {
isExpanded = true;
if (!historicalPricesFetched) {
fetchHistoricalPrices();
historicalPricesFetched = true;
}
}
}
Better async primitives / loading state management
We have a decent solution for indicating the loading state to the user. However, I'm not wholly satisfied with having to manage promises and state flags manually.
I recently watched a presentation of Shawn Wang about data fetching in Svelte in which he uses a few methods for properly loading data and showing this to users.
I'll probably play around with a few solutions to see which one suits best for this use case and then follow up with a post here.