DEV Community

Cover image for State management in Svelte apps
Ryan Cooke
Ryan Cooke

Posted on

State management in Svelte apps

HTTP was invented as a stateless protocol, which means that each request fully encapsulates all of the information necessary to return a correct response. So historically, web pages never had to worry about managing state - each request to a URL with parameters or with a form submission would receive a response with all of the HTML that the browser needed to render content.

But now on today's Internet, we have web applications - which are pretty complicated bundles of JavaScript code, and they load data in a variety of ways, sometimes fetching HTML, but just as often getting JSON data through APIs. And yet for a lot of web app frameworks, client state is treated as an add-on, offered via libraries, or worse, by comingling state with view components. This feels a lot less mature than server-side apps, where there are databases that can serve as a single source of truth for data, support flexible queries i.e. SQL, and offer guarantees for correctness i.e. ACID properties. (OK, Notion did build a SQL database for their web app, and boy did they make it complicated)

So it's up to the humble developer to decide how they will manage state, and for any non-trivial web app, this quickly turns into a tangled mess of cross-component dependencies, bi-directional props, and state duplication. Fortunately, Svelte ships with a fantastic module called "stores" which is simple and powerful and can be shared across a codebase.

In this post, I'll describe a tiered approach I've been using for client-side web application state. It separates different concerns that are common when it comes to state, making an opinionated division between component state, view state, client-side persistent state, and server-side state.

What am I optimizing for? The best User eXperience possible - I want interaction that is responsive without delay, changes to state should be optimistic (as in immediately re-rendered), and feedback is obvious when something goes wrong. But this comes with a tradeoff, namely tightly coupled layers of code. This will be more clear with code samples below.

The app I'm building is an email client. It has a bunch of state - each email message has headers and content, messages are grouped into threads, and threads have labels. There are filtered lists of threads, chronological lists of messages in a thread, and actions like adding a label to a thread or deleting it. This client talks to a mail server through an API, and that is the source of truth, but it also keeps a local database that is a semi-processed copy of all emails to support offline mode.

First a visual, if I were to stack the "layers" of state in my app, it would look something like this -

State in tiers

Let me explain each layer:

  • Components keep track of whatever state is necessary to render their portion of the view correctly. But this does not include the main content of the component! For example, think of a list of threads - the list has an order/pagination/filters/etc. that is kept in the component. However the list of threads itself is not managed by the component.
  • View state is the main space for whatever state the app is keeping in memory. Continuing with the thread list from above, it's the page of threads with their message data. Other examples of view state include - a selected (or "active") thread, any filters that apply to the list of threads, and all of the labels assigned to a thread. I use Svelte stores as the interface for this layer, so that state changes can be communicated to any component with a subscription.
  • Client state is what I call anything persisted between sessions, when the browser or tab is closed and re-opened. In my app, I leverage IndexedDB to store a copy of all emails synced from the server, which allows my app to work offline and load instantly. To load a page of threads, the view layer can query the database directly and populate the Svelte store, rather than making an HTTP request to the server.
  • Server state is everything not on the client, which could include SSR components or third-party APIs. In my case, it's the source-of-truth email server accessed through a REST API, and client-side state changes must be synced with the server for durability.

Here's an annotated version of the visual from above -

State by component

A lot of apps I see conflate component and view state. The problem with this is that changes become hard to manage, as state becomes deeply nested through props or local variables. Components might have different behavior across a change lifecycle, with some re-rendering optimistically while others need to wait for changes to be fully persisted through to the API.

Let's look at some code. Here's a message list component -

// ./src/components/ThreadList.svelte
<script>
  import { filters, activeThread, threadList } from './stores'
  import { getPage } from './view-state'

  let order = 'prev',
    stillLoading = true
  // reactively reload page if filters change
  $: loadPage($filters)

  function loadPage(filters) {
    getPage(filters, order)
      .then(() => {
        stillLoading = false
        if (
          $threadList.findIndex((t) => t.id == $activeThread?.id) == -1
        ) {
          activeThread.set($threadList.at(0))
        }
        return tick()
      })
      .then(() => scrollToActiveThread())
  }

  function scrollToActiveThread() {
    if ($activeThread == undefined) return
    //... a bunch of bounding box calculations
  }
</script>

<div>
  <ul class="mr-2 font-serif">
    {#each $threadList as thread (thread.id)}
      <li
        id="thread-{thread.id}"
        class="border-b border-l-4 .thread-item"
        class:bg-white={thread.id == $activeThread?.id}
      >
      <!-- a bunch of markup -->
      </li>
    {:else}      
      <li class="border-b border-gray-100 border-l-4">
        <div class="py-6 px-8 animate-ellipsis">
          {#if stillLoading}
            Loading
          {:else}
            No messages
          {/if}
        </div>
      </li>
    {/each}
  </ul>
</div>
Enter fullscreen mode Exit fullscreen mode

The markup here is simplified, but it shows how the state for a list of threads is managed. The component never populates the list, in fact it doesn't have a local variable corresponding to a list at all. Instead, the list is contained in a Svelte store called threadList. The component does make a call to getPage(filters, order, offset), a method exposed by the view-state.js, which will fetch a page and set the threadList store. The component renders markup directly from the threadList store to leverage Svelte's excellent reactive capabilities.

Here's stores.js, it's pretty simple, just a place for declaring and exporting state that is shared across components. I've added comments that explain why each store is used to manage state -

//./src/lib/stores.js
import { writable } from 'svelte/store'

// A single page of threads fetched from local persistence
// NB: this list is only displayed on the ListThreads.svelte component,
// but is kept as a Svelte store so that optimistic updates to a single
// thread can be rendered without needing to re-fetch the entire page.
export const threadList = writable([])
// The currently selected thread, either from the threadList
// or via direct navigation.
// NB: This is the thread that all mutations apply to, such as
// adding a label or deleting a thread.
export const activeThread = writable(undefined)
// All of the labels in the mailbox, kept in memory for quick retrieval
export const labels = writable([])
// Any filters that should be used when fetching a page of threads
// NB: Filters are set via one component (TopNav), but used in another
// component (ListThreads) when fetching a page.
export const filters = writable([])
Enter fullscreen mode Exit fullscreen mode

Now let's look at view-state.js, where the bulk of the logic is happening -

//./src/lib/view-state.js
import { get } from 'svelte/store'
import { labels, activeThread, threadList } from './stores'
import { Thread } from './structs'

export async function getPage(filters, order = 'prev') {
  const db = await database()
  const allLabels = get(labels)

  const txn = db.transaction('threads')
  // Query client-side database for a page of threads
  const query = txn.objectStore('threads').index('ts').openCursor(null, order)

  // Wrap query callback in a Promise, so that the
  // caller of getPage can do post-query processing of list
  return new Promise((resolve) => {
    const page = []
    query.onsuccess = (request) => {
      const cursor = request.target.result
      if (cursor) {
        const thread = cursor.value
        // IDB provides fast cursors, but no filtering capabilities
        // so we match filters in code. The thread must have all labels
        // that appear as filters, so do a conjunction test.
        const matchFilters =
          filterIds.filter((fi) => thread.labelIds.includes(fi)).length == filterIds.length

        if (matchFilters) page.push(new Thread(thread, allLabels))
        if (page.length < 50) {
          cursor.continue()
        } else {
          // Page if full with 50 threads
          // set Svelte store and resolve promise
          threadList.set(page)
          resolve()
        }
      } else {
        // Cursor is not set, meaning we've reached the end.
        // set Svelte store and resolve promise
        threadList.set(page)
        resolve()
      }
    }
    query.onerror = () => resolve({ error: `Error loading page: ${query.error}` })
  })
}
Enter fullscreen mode Exit fullscreen mode

If you trace the code, the component ThreadList.svelte calls our view-state function getPage(...), which then queries our client-side state in IndexedDB. The result from the query gets set in the view-state Svelte store threadList, which ThreadList.svelte is reactively using to render the page of threads.

Here's what it looks like in our state diagram:

Fetch state

Ok, this looks like a lot of complexity for simply rendering a list of email threads. I don't disagree! Let's look at a better example, adding a label to an email thread. Optimistically mutating state is where this structure starts to make sense, as each layers of state can handle a single concern.

I'll skip the component (there's a bunch of modal stuff), and just show the function in view-state.js, which calls a function to update the client-side IndexedDB in client-state.js -

//./src/lib/view-state.js
import { get } from 'svelte/store'
import { labels, activeThread, threadList } from './stores'
import { Thread } from './structs'
import { addLabelToThreadDB } from './client-state'

export async function getPage(filters, order = 'prev') {
  //... code is above
}

export async function addLabelToActiveThread(label) {
  const thread = get(activeThread)

  if (thread === undefined) {
    return Promise.resolve({ error: 'Unable to add label to thread, no thread selected' })
  }

  // do nothing if thread already contains label
  if (thread.labelIds.includes(label.id)) {
    return Promise.resolve(thread)
  }

  // optimistically update Svelte stores with new label
  // this will trigger any components that have this state
  // to re-render
  thread.labelIds.push(label.id)
  updateThreadInStores(thread)

  const dbUpdate = addLabelToThreadDB(thread.id, label)

  return dbUpdate
    .then((result) => {
      // something went wrong, undo optimistic update
      if (result.error) {
        thread.labelIds = thread.labelIds.filter((l) => l !== label.id)
        updateThreadInStores(thread)
      }
      return result
    })
}

function updateThreadInStores(thread) {
  activeThread.update((t) => {
    // only update activeThread if it is
    // set to the same thread!
    if (t?.id !== thread.id) return t
    return thread
  })

  threadList.update((tl) => {
    return tl.map((t) => (t.id == thread.id ? thread : t))
  })
}
Enter fullscreen mode Exit fullscreen mode
//./src/lib/client-state.js

async function database() {
  //...open connection to IndexedDB
}

export async function addLabelToThreadDB(threadId, label) {
  const db = await database()
  const txn = db.transaction(['threads'], 'readwrite')
  const store = txn.objectStore('threads')
  return new Promise((resolve, reject) => {
    const getThread = store.get(threadId)
    getThread.onsuccess = ({ target }) => {
      const dbthread = target.result
      if (!dbthread.labelIds.includes(label.id)) {
        dbthread.labelIds.push(label.id)
      }

      const putThread = store.put(dbthread)
      putThread.onsuccess = () => resolve(dbthread)
      putThread.onerror = () => resolve({ error: putThread.error })
    }
    getThread.onerror = () => resolve({ error: getThread.error })
  })
}

export async function removeLabelFromThreadDB(threadId, label) {
  const db = await database()
  const txn = db.transaction(['threads'], 'readwrite')
  const store = txn.objectStore('threads')
  return new Promise((resolve, reject) => {
    const getThread = store.get(threadId)
    getThread.onsuccess = ({ target }) => {
      const dbthread = target.result
      if (dbthread.labelIds.includes(label.id)) {
        dbthread.labelIds = dbthread.labelIds.filter((l) => l !== label.id)
      }

      const putThread = store.put(dbthread)
      putThread.onsuccess = () => resolve(dbthread)
      putThread.onerror = () => resolve({ error: putThread.error })
    }
    getThread.onerror = () => resolve({ error: getThread.error })
  })
}
Enter fullscreen mode Exit fullscreen mode

This code is updating Svelte stores before writing the change to the client-side IndexedDB. The result is that the components will re-render the thread with the new label immediately. The method updateThreadInStores simultaneously updates the activeThread stores, as well as the threadList store that was populated with the getPage call above.

Image description

However if there is an error when persisting the change, view-state.js will simply roll back the stores to their previous state. Again, the user experience is updated almost instantly, and feedback given to the user about the failure.

Image description

Finally, this pattern can be stacked with an API call, to persist changes to the server side. This is implemented by chaining an additional call to the promise that is updating IndexedDB -

//./src/lib/view-state.js

export async function addLabelToActiveThread(label) {
  //...all of the code from above

  return dbUpdate
    .then((dbResult) => {
      // something went wrong in IndexedDB, undo optimistic update
      if (dbResult.error) {
        thread.labelIds = thread.labelIds.filter((l) => l != label.id)
        updateThreadInStores(thread)
        return dbResult
      }
      // client-side state updated, now update API
      return addLabelToThreadAPI(thread, label)
    })
  .then((apiResult) => {
      // something went wrong in the API, undo optimistic update
      if (apiResult.error) {
        thread.labelIds = thread.labelIds.filter((l) => l != label.id)
        updateThreadInStores(thread)
        // also undo database update
        return removeLabelFromThreadDB(thread.id, label)
      }
      return result
    })
}
Enter fullscreen mode Exit fullscreen mode

So at the beginning of this post, I mentioned a tradeoff with this approach - the view-state.js ends up being very tightly-coupled to the client-state and API layers. The view-state code is doing a lot of coordination around updating the client-side database, API, and handling error scenarios between them. You may have also noticed that there are some race conditions in this code which means consistency cannot be guaranteed.

But I've still found this structure to help organize the code, and reduce decisions around the question of "where should this logic go?" that I have to answer pretty often while building apps. Hopefully this helps you with this as well!

Keep on coding

Top comments (0)