How to build an Unsplash Search App with Svelte 3

In this article, we’ll pick up from the previous post that introduced the Svelte 3 library and tackle another exercise that teaches a little more about the features Svelte has to offer.

During the course of working through this tutorial, you will build an application that searches Unsplash and presents the results in an infinitly scrolling list.

Here’s what the completed project will look like:

Prerequistes

This tutorial assumes you have a little bit of prior experience with Svelte 3. It should also be easy enough to follow if you are experienced with React, Vue or other similar JavaScript libraries.

Otherwise, you might want to jump in to Svelte 3 with this introductory tutorial that covers building a todo list application before coming back to this one.

Grab the starter files

Clone this GitHub repository to your computer and cd into the created directory. Then run npm install from the project root to install all the dependencies specified in the package.json file.

After installing the dependencies, start the development server by running npm run dev which should compile the app and make it accessible at http://localhost:5000.

Create the search form component

Create a new Search.svelte file in the src folder of your root directory and add the following code into it:

<script>
  export let query;
  export let handleSubmit;
</script>

<style>
  .search-input {
    width: 100%;
    max-width: 800px;
    border-radius: 4px;
    border: 1px solid #ccc;
    padding: 10px 20px;
    font-size: 20px;
    margin-bottom: 50px;
  }
</style>

<div class="search">
  <form class="search-form" on:submit|preventDefault={handleSubmit}>
    <input bind:value={query} class="search-input" type="search"
    placeholder="Search Unsplash's library of over 1 million photos" />
  </form>
</div>

The way to declare props in Svelte 3 is by using the export keyword on a variable declaration. In the above case, we’re expecting a query prop which is bound to the <input> element, and handleSubmit which is a function called when .search-form is submitted.

Let’s go ahead and create these two properties in the App component while passing them down to Search.

Passing props from parent to child component

Update your src/App.svelte file as shown below:

<script>
  import Search from './Search.svelte';

  const UNSPLASH_ACCESS_KEY =
    '<your unsplash access key>';

  let searchQuery = '';
  let searchTerm = null;
  let totalPages = null;
  let searchResults = [];
  let nextPage = 1;
  let isLoading = false;

  function handleSubmit() {
    searchTerm = searchQuery.trim();
    searchResults = [];
    totalPages = null;
    nextPage = 1;

    if (!searchTerm) return;

    searchUnsplash();
  }

  function searchUnsplash() {
    isLoading = true;

    const endpoint =
      `https://api.unsplash.com/search/photos?query=${searchTerm}&page=${nextPage}&per_page=28&client_id=${UNSPLASH_ACCESS_KEY}`;

    fetch(endpoint)
      .then(response => {
        if (!response.ok) {
          throw Error(response.statusText);
        }
        return response.json();
      })
      .then(data => {
        if (data.total === 0) {
          alert("No photos were found for your search query.")
          return;
        }

        searchResults = [...searchResults, ...data.results];
        totalPages = data.total_pages;

        if (nextPage < totalPages) {
          nextPage += 1;
        }
      })
      .catch(() => alert("An error occured!"))
      .finally(() => {
        isLoading = false;
      });
  }
</script>

<style>
  .App {
    width: 100%;
    max-width: 1500px;
    padding: 20px;
    margin: 0 auto 50px;
    text-align: center;
  }

  h1 {
    font-size: 50px;
    margin-top: 30px;
    margin-bottom: 30px;
  }
</style>

<main class="App">
  <h1>Unsplash Search App</h1>

  <Search bind:query={searchQuery} handleSubmit={handleSubmit} />
</main>

The searchQuery variable is bound to the query prop with the help of the bind keyword. This bind:property={value} syntax is what allows data to flow from child component to parent component.

On the other hand, the handleSubmit prop points to the handleSubmit function created above. After checking if the searchTerm is not an empty string, the searchUnsplash() function is invoked which sends the query to the Unsplash API and appends the results to the searchResults array.

To use the Unsplash API, you need to create a free account on their website first. Follow the instructions on this page to do so, and register a new application. Once your app is created, copy the access key and paste it in place of <your unsplash access key> above.

Render the search results

As seen from the above section, the searchResults array holds incoming results from the Unsplash API. What we want to do is render an image card for each result in the array. To do so, first create a SearchResults.svelte component in the src directory as follows:

<script>
  import PhotoCard from './PhotoCard.svelte';

  export let results;
</script>

<style>
  .search-results {
    list-style: none;
    display: grid;
    gap: 40px 20px;
    grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
  }
</style>

<ul class="search-results">
  {#each results as result (result.id)}
    <PhotoCard photo={result} />
  {/each}
</ul>

Then create the PhotoCard component in the same directory as well:

<script>
  export let photo;
</script>

<style>
  .photo-card {
    animation: show 0.5s forwards ease-in-out;
  }

  .photo {
    width: 100%;
    object-fit: cover;
    height: 300px;
    background-color: #ccc;
    margin-bottom: 10px;
  }

  .photographer {
    display: inline-block;
  }

  .photographer a {
    color: #333;
    text-decoration: none;
  }

  .photographer a:hover {
    text-decoration: underline;
  }

  @keyframes show {
    0% {
      opacity: 0;
      transform: translateY(100px);
    }

    100% {
      opacity: 1;
      transform: translateY(0);
    }
  }
</style>

<li class="photo-card">
  <a href={photo.links.html} rel="noopener noreferrer" target="_blank">
    <img class="photo" src={photo.urls.small} alt={photo.description || ""}>
  </a>
  <span class="photographer">
    <a href={photo.user.links.html} rel="noopener noreferrer"
      target="_blank">{photo.user.name}</a>
  </span>
</li>

In SearchResults, you can see that the PhotoCard component is rendered for each object in the array. Pretty straightforward to reason about if you ask me.

The final step is to import SearchResults in App.svelte and pass it the searchResults array as props.

<script>
  import Search from './Search.svelte';
  import SearchResults from './SearchResults.svelte';

  // rest of the code
</script>

<style>
  /* App styles */
</style>

<main class="App">
  <h1>Unsplash Search App</h1>

  <Search bind:query={searchQuery} handleSubmit={handleSubmit} />

  <SearchResults results={searchResults} />
</main>

Test it out by searching for a generic word. It should render a list of results similar to the GIF below:

When a search request is ongoing, we want to show a loading indicator in the app so that users can know that the search is currently in progress.

Create a new LoadingIndictor.svelte file within the src folder, and populate it with the following code:

<style>
  .spinner {
    margin: 50px auto;
    width: 50px;
    height: 40px;
    text-align: center;
    font-size: 10px;
  }

  .spinner > div {
    background-color: #333;
    height: 100%;
    width: 6px;
    display: inline-block;

    -webkit-animation: sk-stretchdelay 1.2s infinite ease-in-out;
    animation: sk-stretchdelay 1.2s infinite ease-in-out;
  }

  .spinner .rect2 {
    -webkit-animation-delay: -1.1s;
    animation-delay: -1.1s;
  }

  .spinner .rect3 {
    -webkit-animation-delay: -1.0s;
    animation-delay: -1.0s;
  }

  .spinner .rect4 {
    -webkit-animation-delay: -0.9s;
    animation-delay: -0.9s;
  }

  .spinner .rect5 {
    -webkit-animation-delay: -0.8s;
    animation-delay: -0.8s;
  }

  @-webkit-keyframes sk-stretchdelay {
    0%, 40%, 100% { -webkit-transform: scaleY(0.4) }
    20% { -webkit-transform: scaleY(1.0) }
  }

  @keyframes sk-stretchdelay {
    0%, 40%, 100% {
      transform: scaleY(0.4);
      -webkit-transform: scaleY(0.4);
    }  20% {
      transform: scaleY(1.0);
      -webkit-transform: scaleY(1.0);
    }
  }
</style>

<div class="spinner">
  <div class="rect1"></div>
  <div class="rect2"></div>
  <div class="rect3"></div>
  <div class="rect4"></div>
  <div class="rect5"></div>
</div>

The above MIT licenced code was copied off this link in case you’re hunting for loading indicators for your own applications.

Next, let’s import LoadingIndicator in App.svelte and conditionally render it when isLoading is true:

  <script>
    import Search from './Search.svelte';
    import SearchResults from './SearchResults.svelte';
    import LoadingIndicator from './LoadingIndicator.svelte';

    // rest of the code
  </script>

  <style>
    /* App styles */
  </style>

  <main class="App">
    <h1>Unsplash Search App</h1>

    <Search bind:query={searchQuery} handleSubmit={handleSubmit} />

    <SearchResults results={searchResults} />

    <div class="loading-indicator">
      {#if isLoading}
        <LoadingIndicator />
      {/if}
    </div>
  </main>
 

At this point, the loading indicator should display in the brief interlude between when a query is made and when the results are returned.

Load more than one page of results

At the moment, only one page of results is being loaded for a search query. Each page contains 28 photos according to what we specified in the per_page query parameter in the endpoint.

We can load more pages by increasing the value of nextPage in subsequent requests as long it doesn’t exceed the total number of pages available for a particular search term.

A standard way to load more pages would be to have a “More results” button of some sort that the user has to click to load the next page into view. Another common approach is to have some pagination so that the user can jump to a specific page.

I’m going to show you a different way that is similar to how it’s done on the Unsplash website. As the user scrolls to the bottom of the page, the next page is appended to the view automatically. If you’re thinking “Infinite scrolling”, you’ve got that right! We’ll make use of the Intersection Observer API to achieve this.

The basic idea behind the Intersection Observer API is that it allows you to react to an element scrolling into view on a webpage. To use it, you need to create an observer instance and attach it to a root element, usually the browser viewport. Then you request this observer to monitor a child of the root element and execute a callback function when the element scrolls into view.

Truth be told, it’s a little more nuanced than that, and while I’m unable to provide an in-depth explanation for how the API works in this article, you can always investigate on your own. The MDN resouce linked above is a good place to start.

Jumping back to our app, we’re going to make use of Svelte’s onMount lifecycle hook so that we can create the observer when the App component is first rendered to the DOM.

First, import onMount from the svelte package above the other imports in App.svelte:

<script>
  import { onMount } from 'svelte';
  import Search from './Search.svelte';

  // rest of the code
</script>

Then place the following chunk of code above the handleSubmit function:

<script>
  // ...

  let observer;
  let target;

  const options = {
    rootMargin: '0px 0px 300px',
    threshold: 0,
  };

  const loadMoreResults = entries => {
    entries.forEach(entry => {
      // If new search or if ongoing search
      if (nextPage === 1 || isLoading) return;

      // target is intersecting the viewport
      if (entry.isIntersecting) {
        searchUnsplash();
      }
    });
  };

  onMount(() => {
    observer = new IntersectionObserver(loadMoreResults, options);
    target = document.querySelector('.loading-indicator');

    observer.observe(target);
  });

  // rest of the code
</script>

The code above basically calls loadMoreResults when the .loading-indicator element is about 300px below the viewport. If the conditions are satisfied, searchUnsplash is called again and the next page is added to the searchResults array which triggers an update to the view.

To wrap up, we’ll unobserve the target element once all the pages for a search have been loaded into view. This prevents unnecessary requests to Unsplash’s API after all the pages for a search query has been loaded into view.

We can easily do this in the finally method in searchUnsplash:

    .finally(() => {
      isLoading = false;

      if (nextPage >= Number(totalPages)) {
        observer.unobserve(target);
      }
    });

The problem this creates is that if the target is unobserved and a new search is performed, only the first page of results will be loaded by virtue of the target being unobserved.

We can fix this by observing the target element every time a new search is performed rather than just when the component is mounted. This ensures that even if target is unobserved at on the last search page, it will be observed again for a new search query.

Move the following line from the onMount method to handleSubmit, just before the call to searchUnsplash():

observer.observe(target);

There you go! We now have a fully functional Unsplash Search application, and it didn’t take a lot of work to build it out thanks to the help of Svelte and the Intersection Observer API.

Wrap up

The complete code for this tutorial can be found in this GitHub repository. If you’re interested in learning more about Svelte, consider subscribing to my email newsletter so you do not miss future tutorials.

Thanks for reading!