Forms and Client-Server Interaction

State and Client-Side APIs


Learning Objectives

  • You know how to initialize the state from the API when components are mounted.
  • You understand the importance of planning how and when to read the state from the API to avoid unnecessary or duplicate API calls.
  • You know how to use the $derived rune to create reactive variables based on other reactive variables.

At the end of the chapter Persisting State with LocalStorage in the part Client-Side Pages, Components, and Interactivity, we finished functionality for persisting the shared state in localStorage.

The file bookState.svelte.js that stored the books to localStorage looked similar to the following.

import { browser } from "$app/environment";

const BOOKS_KEY = "books";
let initialBooks = [];
if (browser && localStorage.getItem(BOOKS_KEY) != null) {
  initialBooks = JSON.parse(localStorage.getItem(BOOKS_KEY));
}

let bookState = $state(initialBooks);

const saveBooks = () => {
  localStorage.setItem(BOOKS_KEY, JSON.stringify(bookState));
};

const useBookState = () => {
  return {
    get books() {
      return bookState;
    },
    getOne: (id) => {
      return bookState.find((b) => b.id === id);
    },
    addBook: (book) => {
      bookState.push(book);
      saveBooks();
    },
    removeBook: (book) => {
      bookState = bookState.filter((b) => b.id !== book.id);
      saveBooks();
    },
    updateBook: (book) => {
      const index = bookState.findIndex((b) => b.id === book.id);
      if (index !== -1) {
        bookState[index] = book;
        saveBooks();
      }
    },
  };
};

export { useBookState };

The key functionality of the above code is that it (1) uses Svelte’s $state to create a reactive variable, (2) initializes the state from localStorage, (3) saves the state to localStorage whenever the state changes, and (4) exposes a module-level singleton with functions that can be used to access and modify the state.

Loading Exercise...

Now that we have a client-side API module, we can use it to replace the localStorage in our Svelte components.

Initializing the State with the API

To use the server-side API instead of localStorage, we need to replace the interactions with localStorage with interactions with the API. We further need to decide how and when to read the state from the API — should we read all the books at start, or should we read them only when they are needed?

As we know about how components are mounted, we can read the books that are needed at the time of mounting a component. To avoid unnecessary or duplicate API calls, this needs to be planned — that is, we need to decide which components will read the books, and when.

Reading books to the state

There are two primary ways how books are used. The page at /books lists all the books in the system, and the page at /books/[id] shows a single book. To support these two use cases, we need to be able to read all the books, and to read a single book by its identifier.

To get started, let’s modify the bookState.svelte.js file to import the booksApi module, and then to provide two additional functions: a function to read all the books, and a function to read a single book by its identifier. Both functions will use the booksApi module we created in the previous chapter.

import { browser } from "$app/environment";
import * as booksApi from "$lib/apis/booksApi.js";

let bookState = $state([]);

const initBooks = async () => {
  if (browser) {
    bookState = await booksApi.readBooks();
  }
};

const initBook = async (id) => {
  if (browser) {
    const book = await booksApi.readBook(id);
    if (book && !bookState.find((b) => b.id === book.id)) {
      bookState.push(book);
    }
  }
};

// ...

export {
  initBooks,
  initBook,
  useBookState
};

Now, we have two functions: initBooks and initBook. The initBooks function reads all the books from the API and sets the state to the returned books. The initBook function reads a single book by its identifier, and if the book is not already in the state, it adds it to the state.

Loading Exercise...

Reading all books at /books

As all books are listed when the user navigates to the /books page, it makes sense to read all the books when the /books page is loaded.

Modify the +page.svelte file in the src/routes/books folder so that it imports the book state and calls the initBooks function using $effect rune:

<script>
  import { initBooks } from "$lib/states/bookState.svelte.js";
  import BookForm from "$lib/components/BookForm.svelte";
  import BookList from "$lib/components/BookList.svelte";

  $effect(() => {
    initBooks();
  });
</script>

<h1>Books</h1>

<BookList />

<h2>Add a book</h2>

<BookForm />

Now, when the user navigates to the books page, the initBooks function is called, and all the books are read from the API and set to the state.

Reading a single book

Similarly, the initBook function can be called when the user navigates to the /books/[bookId] page to read a single book by its identifier.


Loading Exercise...

Using the API to Modify the State

Now we have the state initialized from the API, but we still need to modify the state when books are added, removed, or updated. To do this, we need to replace the interactions with localStorage with interactions with the API.

Retrieving all books

Retrieving all books does not change, as the useBookState directly provides a getter to the bookState object. Svelte augments the getter function so that changes to the bookState are reflected in all components that use the getter function.

import { browser } from "$app/environment";
import * as booksApi from "$lib/apis/booksApi.js";

let bookState = $state([]);

const initBooks = async () => {
  if (browser) {
    bookState = await booksApi.readBooks();
  }
};

const initBook = async (id) => {
  if (browser) {
    const book = await booksApi.readBook(id);
    if (book && !bookState.find((b) => b.id === book.id)) {
      bookState.push(book);
    }
  }
};

const useBookState = () => {
  return {
    get books() {
      return bookState;
    },
// ...

Retrieving a single book

Our prior implementation for retrieving a single book used a function getOne, which searched for a book from the existing list of books and returned it. The object returned by the getOne function is not reactive, however. The reason our prior implementation has worked is that the book state has been initiated from the localStorage synchronously in the bookState.svelte.js; this has resulted in the getOne function finding the book from the state.

However, when working with APIs or with any asynchronous functionality, we cannot expect that the asynchronous calls would have been finished when we retrieve a single book. Due to this, we drop the getOne function — if we need an individual book, we’ll look for it from the books list returned by the books getter function.

Loading Exercise...

Adding a book

To add a book, we need to use the createBook function of the booksApi. The function makes a POST request to the API to create a new book. We will also need to update the state of the bookState variable once the book has been added.

Similar to how we retrieved all the books, we will use the then function associated with the promise returned by the asynchronous createBook function to update the state of the bookState variable once the book has been added.

import { browser } from "$app/environment";
import * as booksApi from "$lib/apis/booksApi.js";

let bookState = $state([]);

const initBooks = async () => {
  if (browser) {
    bookState = await booksApi.readBooks();
  }
};

const initBook = async (id) => {
  if (browser) {
    const book = await booksApi.readBook(id);
    if (book && !bookState.find((b) => b.id === book.id)) {
      bookState.push(book);
    }
  }
};

const useBookState = () => {
  return {
    get books() {
      return bookState;
    },
    addBook: (book) => {
      booksApi.createBook(book).then((newBook) => {
        bookState.push(newBook);
      });
    },
// ...

We could alternatively change the function to be asynchronous, and use await to wait for the book to be added before updating the state.

Loading Exercise...

Removing a book

To remove a book, we use the deleteBook function of the booksApi. The deleteBook function returns a promise that resolves to the deleted book. We will also need to update the state of the bookState variable once the book has been removed.

import { browser } from "$app/environment";
import * as booksApi from "$lib/apis/booksApi.js";

let bookState = $state([]);

const initBooks = async () => {
  if (browser) {
    bookState = await booksApi.readBooks();
  }
};

const initBook = async (id) => {
  if (browser) {
    const book = await booksApi.readBook(id);
    if (book && !bookState.find((b) => b.id === book.id)) {
      bookState.push(book);
    }
  }
};

const useBookState = () => {
  return {
    get books() {
      return bookState;
    },
    addBook: (book) => {
      booksApi.createBook(book).then((newBook) => {
        bookState.push(newBook);
      });
    },
    removeBook: (book) => {
      booksApi.deleteBook(book.id).then((removed) => {
        bookState = bookState.filter((b) => b.id !== removed.id);
      });
    },
// ...

Like earlier, we could also change the function to be asynchronous, and use await to wait for the book to be removed before updating the state. This would look as follows:

    removeBook: async (book) => {
      const removed = await booksApi.deleteBook(book.id);
      bookState = bookState.filter((b) => b.id !== removed.id);
    },

Updating a book

Finally, to update a book, we use the updateBook function of the booksApi. The updateBook function returns a promise that resolves to the updated book. We will also need to update the state of the bookState variable once the book has been updated.

import { browser } from "$app/environment";
import * as booksApi from "$lib/apis/booksApi.js";

let bookState = $state([]);

const initBooks = async () => {
  if (browser) {
    bookState = await booksApi.readBooks();
  }
};

const initBook = async (id) => {
  if (browser) {
    const book = await booksApi.readBook(id);
    if (book && !bookState.find((b) => b.id === book.id)) {
      bookState.push(book);
    }
  }
};

const useBookState = () => {
  return {
    get books() {
      return bookState;
    },
    addBook: (book) => {
      booksApi.createBook(book).then((newBook) => {
        bookState.push(newBook);
      });
    },
    removeBook: (book) => {
      booksApi.deleteBook(book.id).then(() => {
        bookState = bookState.filter((b) => b.id !== book.id);
      });
    },
    updateBook: (book) => {
      booksApi.updateBook(book.id, book).then((updatedBook) => {
        const index = bookState.findIndex((b) => b.id === updatedBook.id);
        if (index !== -1) {
          bookState[index] = updatedBook;
        }
      });
    },
  };
};

export { useBookState };

Similar to the earlier examples, we could also use async/await to make the function asynchronous and use await to wait for the book to be updated before updating the state. This would look as follows:

    updateBook: async (book) => {
      const updatedBook = await booksApi.updateBook(book.id, book);
      const index = bookState.findIndex((b) => b.id === updatedBook.id);
      if (index !== -1) {
        bookState[index] = updatedBook;
      }
    },

Now, we’ve just plugged in the client-side API to the shared state, and we can use the same functionality as before. Instead of the books being stored in the localStorage, they are now stored in the server-side API; anyone accessing the application would see the same books.

The reason why this works is that the form fields that we use for entering data correspond to the book data that the server-side API expects, and the API returns the data in the same format as the localStorage did.

Loading Exercise...

Showing a single book

Earlier, we dropped the getOne function from useBookState. The reason for this was that the object that getOne returns is not reactive, and that we cannot rely on the state being initiated when getOne is called.

An asynchronous function used to initialize the state can take plenty of time to finish.

Let’s look into how book information for a single book would be shown.

Book.svelte and page for an individual book

In Forms and Form Events, we drafted a Book.svelte component that looked as follows:

<script>
  let { bookId } = $props();
  import ChapterList from "$lib/components/books/ChapterList.svelte";
  import { useBookState } from "$lib/states/bookState.svelte.js";

  let bookState = useBookState();

  let book = bookState.getOne(bookId);
</script>

{#if book}
  <h1>{book.title}</h1>

  <p><strong>Description:</strong> {book.description}</p>
  <p><strong>Published at:</strong> {book.published_at}</p>
  <p><strong>Page count:</strong> {book.page_count}</p>
{:else}
  <h1>Loading...</h1>
{/if}

<ChapterList {bookId} />

As the getOne function is no longer available, we need to modify the component to show the book information differently. Let’s start by simplifying the component to just show the book identifier. Modify Book.svelte to show the following:

<script>
  let { bookId } = $props();
</script>

<h1>Book id {bookId}</h1>

Then, modify src/routes/books/[bookId]/+page.svelte file as follows to import and show the individual book, also adding a call to initBook to load book details. We drop the chapter-specific functionality for now, but will add it back in the next chapter.

<script>
  import Book from "$lib/components/Book.svelte";
  import { initBook } from "$lib/states/bookState.svelte.js";

  let { data } = $props();

  let bookId = parseInt(data.bookId);

  $effect(() => {
    initBook(bookId);
  });
</script>

<Book bookId={bookId} />

Showing book information

Then, let’s modify the Book.svelte component in the src/lib/components folder to display the book information. Let’s first just show the title of a single book.

<script>
  import { useBookState } from "$lib/states/bookState.svelte.js";

  let { bookId } = $props();

  let bookState = useBookState();
</script>

<h1>
  {bookState?.books?.find((book) => book.id === bookId)?.title ?? "Loading..."}
</h1>

Above, as getOne is no longer available, we search for the specific book from the books in bookState. If a book is found, we show its title. Otherwise, we show the text “Loading…”.

Loading Exercise...

If we would wish to show the rest of the details as well, including description, publication date, and page count, the above approach would become rather cumbersome.

Rune $derived

Svelte has a rune $derived that’s a perfect fit for our scenario. The $derived rune is used for creating a reactive variable based on other reactive variables. As an example, the following code creates a derived variable previous that is always one smaller than the reactive variable count.

<script>
  let count = $state(0);
  let previous = $derived(count - 1);
</script>

<button onclick={() => count++}>Increment</button>

<p>Previous: {previous}, Count: {count}</p>

Now, whenever the user presses the button, the count variable is incremented, and the previous variable is automatically updated to be one smaller than the count variable.

Loading Exercise...

The $derived rune takes an expression that evaluates to a value, and it automatically updates the derived variable whenever any of the reactive variables used in the expression change. We can use this to create a derived variable book that is always the book with the identifier bookId from the bookState.books array.

<script>
  import { useBookState } from "$lib/states/bookState.svelte.js";

  let { bookId } = $props();

  let bookState = useBookState();

  let book = $derived(bookState.books.find((book) => book.id === bookId));
</script>

<h1>{book ? book.title : "Loading..."}</h1>

Now, whenever the bookState.books array changes, the book variable is automatically updated to the book with the identifier bookId. If no such book exists, the book variable is set to undefined.

Loading Exercise...

With the derived book variable, we can easily show the rest of the book details as well.

<script>
  import { useBookState } from "$lib/states/bookState.svelte.js";

  let { bookId } = $props();

  let bookState = useBookState();

  let book = $derived(bookState.books.find((book) => book.id === bookId));
</script>

<h1>{book ? book.title : "Loading..."}</h1>

<p><strong>Description:</strong> {book.description}</p>
<p><strong>Published at:</strong> {book.published_at}</p>
<p><strong>Page count:</strong> {book.page_count}</p>
Loading Exercise...

Summary

In summary:

  • When replacing a localStorage-based state with a server-side API-based state, we need to initialize the state from the API when the components are mounted, and we need to replace the interactions with localStorage with interactions with the API. Any functionality that explicitly relied on synchronous functionality (e.g. retrieving a single book) needs to be changed.
  • The way how the state is initialized depends on the use case, as we do not wish to make unnecessary API calls. In our example, we read all the books when the /books page is loaded, and we read a single book when the /books/[id] page is loaded.
  • If a state is page-specific, it can be initialized in the +page.svelte file of the page using the $effect rune.
  • The $derived rune can be used to create reactive variables based on other reactive variables. This is especially useful when we need to derive a value from a state that may change over time (e.g., when an asynchronous function is used to initialize the state).
Loading Exercise...