DEV Community

Connie Leung
Connie Leung

Posted on

Day 29 - Add a Loader and Error State

On day 29, I add a loader (A <div>Loading ...</div>) to show that the page is loading the data.

It is incredibly easy in Angular 20 because it is a built-in functionality in httpResource. In Vue 3, I installed vueuse and applied the useFetch composable to initiate the network requests.

In SvelteKit, a loader can be implemented with the navigating object in $app/state. It also has an error helper function to set the HTTP status code and an error message.

Framework Approach
Vue 3 vueuse's useFetch Composable
SvelteKit navigating and the error helper function
Angular 20 built-in in httpResource

Implement loading and error states in Home

Vue 3 application

Install vueuse/core

npm install --save-exact @vueuse/core
Enter fullscreen mode Exit fullscreen mode

Replace theusePost composable with the useFetch composable.

export const postsUrl = 'https://jsonplaceholder.typicode.com/posts'
export const usersUrl = 'https://jsonplaceholder.typicode.com/users'
Enter fullscreen mode Exit fullscreen mode
<script setup lang="ts">
import PostCard from '@/components/PostCard.vue';
import { postsUrl } from '@/constants/apiEndpoints';
import type { Post } from '@/types/post';
import { useFetch } from '@vueuse/core';

const {
  data: posts,
  isFetching,
  error,
} = useFetch<Post[]>(postsUrl).json()
</script>
Enter fullscreen mode Exit fullscreen mode

The userFetch accepts a URL that is either a string or a ref/shallowRef. The .json() function returns data as a JSON.

isFetching is true when data is being loaded and falose when the loading completes.

error returns any error that occurs during the network request.

<template>
  <div v-if="isFetching" class="text-center mb-10">Loading ...</div>
  <div v-if="error" class="text-center mb-10">{{ error }}</div>
  <div v-if="posts" class="flex flex-wrap flex-grow">
    <p class="ml-2 w-full">Number of posts: {{ posts.length }}</p>
    <PostCard v-for="post in posts" :key="post.id" :post="post" />
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

When isFetching is true, thediv element displays the Loading... static text. When there is an error, {{ error }} displays the network error. When the endpoint returns the posts successfully, the v-for directive iterates the array and renders the PostCard component.

SvelteKit application

The loading indicator and error message are displayed in +layout.svelte.

<script lang="ts">
    import { page, navigating } from '$app/state';

    let { children } = $props();
</script>
Enter fullscreen mode Exit fullscreen mode
{#if navigating.to}
    <div>Loading page...</div>
{:else if page.error}
    {page.error.message}
{:else}
    <div class="container">
        {@render children?.()}
    </div>
{/if}
Enter fullscreen mode Exit fullscreen mode

When navigation.to is not null, the page is navigating and is loading the data. Therefore, the div element displays the Loading page... static text.

If page.error references an Error object, page.error.message displays the error message.

import { BASE_URL } from '$lib/constants/posts.const';
import type { Post } from '$lib/types/post';
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';

// retreive all posts
export const load: PageServerLoad = async ({ fetch }) => {
    const postResponse = await fetch(`${BASE_URL}/posts`);

    if (!postResponse.ok) {
        error(404, {
            message: 'Failed to fetch posts'
        });
    }

    const posts = (await postResponse.json()) as Post[];
    return { posts };
};
Enter fullscreen mode Exit fullscreen mode

The load function checks that the response is not ok and the error helper function throws a 404 error with a custom message.

Angular 20 application

import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { PostcardComponent } from '../post/postcard.component';
import { PostsService } from '../post/services/posts.service';

@Component({
  selector: 'app-home',
  imports: [PostcardComponent],
  template: `... inline template ...`,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HomeComponent {
  postService = inject(PostsService);

  postsRes = this.postService.posts;

  posts = computed(() => (this.postsRes.hasValue() ? this.postsRes.value() : []));

  error = computed<string>(() =>
    this.postsRes.status() === 'error' ? 'Error loading the posts.' : '',
  );
}
Enter fullscreen mode Exit fullscreen mode

When the postsRes resource's status is error, the error computed signal returns a custom error message. Otherwise, the error message is an empty string.

@if (postsRes.isLoading()) {
  <div>Loading...</div>
} @else if (error()) {
  <div>Error: {{ error() }}</div>
}
@if (posts(); as allPosts) {
    <div class="flex flex-wrap flex-grow">
      <p class="ml-2 w-full">Number of posts: {{ allPosts.length }}</p>
      @for (post of allPosts; track post.id) {
        <app-postcard [post]="post" />
      }
    </div>
}
Enter fullscreen mode Exit fullscreen mode

When postsRes.isLoading() is true, the div element displays the Loading... static text.

When the error computed signal is evaluated to true, the error message is displayed.

When allPosts is defined, the template displays the number of posts and iterates the array to render the PostComponent.

Implement loading and error states in Post

Vue 3 application

First, the useFetch composable retrieves a post by the path param. This is straightforward because this url depends on path param only and it can be destructured from the useRoute composable.

<script setup lang="ts">
import { postsUrl, usersUrl } from '@/constants/apiEndpoints'
import type { Post } from '@/types/post'
import type { User } from '@/types/user'
import { useFetch } from '@vueuse/core'
import { computed, shallowRef, watch } from 'vue'
import { useRoute } from 'vue-router'

const { params } = useRoute()
const url = `${postsUrl}/${params.id}`

const { 
    data: post, 
    isFetching: isFetchingPost, 
    error: errorPost 
} = useFetch<Post>(url).json()
</script>
Enter fullscreen mode Exit fullscreen mode

When post is retrieved, use the useFetch composable to retrieve the user by the post's user ID.

The user url changes when the user ID changes, so it is a shallowRef.

const userUrl = shallowRef('')

const {
  data: user,
  isFetching: isFetchingUser,
  error: errorUser,
} = useFetch<User>(userUrl, { refetch: true, immediate: false }).json()
Enter fullscreen mode Exit fullscreen mode

The refetch: true means that a new request is made when userUrl changes. Moreover, the userUrl is initially a blank string, so I don't want it to fire immediately. The final useFetchOptions is:

{
     refetch: true,
     immediate: true,
}
Enter fullscreen mode Exit fullscreen mode

Modify the watcher to track the post and update the userUrl shallowRef programmatically.

watch(
  () => ({ ...post.value }),
  ({ userId = undefined }) => (userUrl.value = userId ? `${usersUrl}/${userId}` : ''),
)
Enter fullscreen mode Exit fullscreen mode

When userUrl is not blank, the useFetch composable automatically retrieves a user by the user ID.

const isFetching = computed(() => isFetchingPost.value || isFetchingUser.value)
Enter fullscreen mode Exit fullscreen mode

Add the isFetching computed ref to display a loader when post or user is being loaded.

const error = computed(() => {
  if (errorPost.value) {
    return errorPost instanceof Error ? errorPost.message : 'Error retrieving a post.'
  }

  if (errorUser.value) {
    return errorUser instanceof Error ? errorUser.message : 'Error retrieving a user.'
  }

  return ''
})
Enter fullscreen mode Exit fullscreen mode

Add the error computed ref to display any error message.

<template>
  <div v-if="isFetching" class="text-center my-10">Loading...</div>
  <div v-if="error" class="text-center my-10">{{ error }}</div>
  <div v-if="post && user" class="mb-10">
    <h1 class="text-3xl">{{ post.title }}</h1>
    <div class="text-gray-500 mb-10">by {{ user.name }}</div>
    <div class="mb-10">{{ post.body }}</div>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

Display a loader when isFetching is true and an error message when it is not blank. The template displays the post and user name after both data is loaded successfully.

SvelteKit application

import { BASE_URL } from '$lib/constants/posts.const';
import type { Post } from '$lib/types/post';
import type { PostWitUser, User } from '$lib/types/user';
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';

// retreive a post by an ID
export const load: PageServerLoad = async ({ params, fetch }): Promise<PostWitUser> => {
    const post = (await retrieveResource(fetch, `posts/${+params.id}`, 'Post')) as Post;
    const user = (await retrieveResource(fetch, `users/${post.userId}`, 'User')) as User;

    return {
        post,
        user,
    };
};
Enter fullscreen mode Exit fullscreen mode

The retrieveResource helper function uses the native fetch function to retrieve the post by the post ID. When the post is retrieved successfully, this helper function uses the post's user ID to retrieve the user. Next, the load function returns both the post and user to +page.svelte.

type FetchFunction = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;

async function retrieveResource(fetch: FetchFunction, subPath: string, itemName: string) {
    const url = `${BASE_URL}/${subPath}`;
    const response = await fetch(url);
    if (!response.ok) {
        error(404, {
            message: `Failed to fetch ${itemName}`
        });
    }
    const item = await response.json();

    if (!item) {
        error(404, {
            message: `${itemName} does not exist`
        });
    }

    return new Promise((resolve) => {
        setTimeout(() => resolve(item), 1000);
    });
}
Enter fullscreen mode Exit fullscreen mode

The retrieveResource fetches the item and examines the response. When the response is not ok, it throws a 404 error with the custom message, Failed to fetch ${itemName}. await response.json() resolves the Promise to an object. If the object is undefined, it also throws a 404 error with the custom message, ${itemName} does not exist.

return new Promise((resolve) => {
    setTimeout(() => resolve(item), 1000);
});
Enter fullscreen mode Exit fullscreen mode

The promise creates a delay of one second to simulate the loading behavior.

Angular 20 application

import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
import { UserService } from './services/user.service';
import { Post } from './types/post.type';

@Component({
  selector: 'app-post',
  styles: `
    @reference "../../styles.css";

    :host {
      @apply flex m-2  gap-2 items-center w-1/4 flex-grow rounded overflow-hidden w-full;
    }
  `,
  template: `... inline template ...`,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export default class PostComponent {
  readonly userService = inject(UserService);

  post = input<Post>();

  userRef = this.userService.createUserResource(this.post);

  user = computed(() => (this.userRef.hasValue() ? this.userRef.value() : undefined));

  error = computed<string>(() =>
    this.userRef.status() === 'error' ? 'Error loading the post.' : '',
  );
}
Enter fullscreen mode Exit fullscreen mode
@let myUser = user();
@let myPost = post();
@if (userRef.isLoading()) {
  <div>Loading...</div>
} @else if (error()) {
  <div>Error: {{ error() }}</div>
} @else if (myPost && myUser) {
  <div class="mb-10">
    <h1 class="text-3xl">{{ myPost.title }}</h1>
    <div class="text-gray-500 mb-10">by {{ myUser.name }}</div>
    <div class="mb-10">{{ myPost.body }}</div>
  </div>
} @else {
  <div>Post not found</div>
}
Enter fullscreen mode Exit fullscreen mode

When userRef.isLoading() is true, the div element displays the Loading... static text.

When the error computed signal is evaluated to true, the error message is displayed.

The last elseif displays the post title, post body and user name.

We have successfully implemented a simple loader and an error indicator on each page.

Github Repositories

Resources

Top comments (0)