Mastering State Management in Svelte

Mastering State Management in Svelte

State management is a big deal in web development. It keeps your app's data flowing smoothly and ensures everything works as expected.

In this guide, we'll break down state management in Svelte. We'll cover essential concepts like reactivity and stores and show you how to use them in your projects. By the end, you'll have a solid grasp on how to handle state in Svelte.

Let's get started!

Prerequisites

You should be familiar with the following technologies to follow along this guide:

  1. Basic understanding of Svelte

  2. HTML, CSS, and JavaScript knowledge

Reactivity

Reactivity is at the core of Svelte's state management. This feature is the engine that drives the synchronization between the Document Object Model (DOM) and the application's state.

Let's explore some mechanisms you can use to achieve reactivity within your Svelte application.

Variable Assignments

The simplest form of reactivity in Svelte is through the use of variables. Variables in Svelte are reactive by default, meaning any change to their value (by reassigning them) triggers an update in the corresponding DOM elements.

Let's look at an example below:

<script>
  let count = 0;
  let timestamps = [];

  function increment() {
    count += 1;
    timestamps = [...timestamps, new Date(Date.now())]
  }
</script>

<button on:click={increment}>
  Clicked {count} {count === 1 ? 'time' : 'times'}
</button>

<ul>
{#each timestamps as timestamp, index}
  <li>Click {index + 1} was at {timestamp.toLocaleString()}</li>
{/each}
</ul>

In this example, the count and timestamps variables are reactive. Whenever their value changes (in this case, when the button is clicked), Svelte automatically updates the DOM to reflect the new values.

Reactive Declarations

Reactive declarations allow you to define reactive variables derived from other states. By using reactive declarations to define derived states, you can ensure that any changes to their dependencies trigger an update to their value.

From the previous example, let's say you want a text under the button that shows the value of count multiplied by 2. You can create a doubled variable as a reactive value:

<script>
  let count = 0;
  $: doubled = count * 2;

  function increment() {
    count += 1;
  }
</script>

<button on:click={increment}>
  Clicked {count} {count === 1 ? 'time' : 'times'}
</button>
<p>Count doubled: {doubled}</p>

By declaring doubled as a reactive declaration ($: doubled = count * 2), it will be automatically updated whenever count changes. This behavior simplifies managing derived states and ensures the UI stays in sync with the underlying data.

Reactive Statements

Sometimes, you may want to run statements or blocks of code whenever any variables referenced within them change. This is where reactive statements come into the picture. Let's look at an example below:

<script>
  let count = 0;

  $: console.log(`Current count is ${count}`)

  $: {
    console.log(`Fun fact: ${count} is the number that comes after ${count - 1}`)
  }

  function increment() {
    count += 1;
  }
</script>

<button on:click={increment}>
  Current count is {count}
</button>

In this example, we use reactive statements to log messages to the console whenever the value of count changes.

You can also use control flow constructs like if blocks and switch blocks within reactive statements:

<script>
  let count = 0;
  let text = "";

  $: switch(true) {
    case count === 0:
      text = "Click the button above ☝️"
      break;
    case count >= 1 && count < 5:
      text = "Keep clicking"
      break;
    case count === 5:
      text = "Count reached 5!"
      break;
  }

  $: if (count > 5) {
    text = "Okay, you can stop clicking now..."
  }

  function increment() {
    count += 1;
  }
</script>

<button on:click={increment}>
  Current count is {count}
</button>
<p>{text}</p>

Props

In addition to its reactivity, Svelte offers mechanisms for managing the state through props. Props provide a flexible way to pass data from a component to its children.

Consider a scenario where you have a parent component that needs to display and update a name to show a personalized greeting. You

Consider a scenario where you have a parent component that displays and updates a name via its children. You can pass the data needed from the parent to its children through props:

<!-- App.svelte -->
<script>
  import DisplayName from "./DisplayName.svelte"
  import ChangeName from "./ChangeName.svelte"

  let name = "John"
  let names = ["John", "Ralph", "Alice", "Bob", "Sasha", "Emily"]

  function changeName() {
    const index = names.indexOf(name);
      const nextIndex = index === names.length - 1 ? 0 : index + 1;
      name = names[nextIndex];
  }
</script>

<ChangeName {changeName} />
<DisplayName {name} />

In this snippet:

  • The name variable is passed to the DisplayName component using the {prop} syntax (shorthand for prop={prop}).

  • We also pass the changeName function into the ChangeName component to update the name variable.

Next, we need to declare the name and changeName props in their respective components using the export keyword in order to use them:

<!-- ChangeName.svelte -->
<script>
  export let changeName;
</script>

<button on:click={changeName}>Change name</button>
<!-- DisplayName.svelte -->
<script>
  export let name;
</script>

<p>Hello, {name}!</p>

Next, let's take a look at events in Svelte.

Events

Events in Svelte are crucial in facilitating communication between components. Svelte provides various techniques for handling events, including component events, event forwarding, and DOM event forwarding.

Component Events

Component events allow child components to communicate with their parent components by dispatching custom events. This feature enables child components to notify their parent components about specific actions or changes.

Consider a scenario where a child component emits an event to notify its parent component about a button click:

<!-- ChildComponent.svelte -->
<script>
  import { createEventDispatcher } from 'svelte';

  const dispatch = createEventDispatcher();

  function handleClick() {
    dispatch('message', { message: 'Button clicked!' });
  }
</script>

<button on:click={handleClick}>Click me</button>
<!-- App.svelte -->
<script>
  import ChildComponent from "./ChildComponent.svelte";

  function handleButtonClick(event) {
    alert(event.detail.message);
  }
</script>

<ChildComponent on:message={handleButtonClick} />

In this example:

  • ChildComponent.svelte:

    • This component uses Svelte's createEventDispatcher to create an event dispatcher.

    • The handleClick function dispatches a custom event named message with a payload containing the message "Button clicked!" when the button is clicked.

  • App.svelte:

    • The parent component imports and includes the ChildComponent.

    • It defines a handleButtonClick function that alerts the message from the event's detail when the custom message event is received.

    • The ChildComponent is wired to the handleButtonClick function via the on:message directive, ensuring that the parent component listens for the message event emitted by the child component.

Event Forwarding

Event forwarding involves passing events from one component to another within the component hierarchy. It also allows intermediate components to intercept and modify events before forwarding them to their intended destination, providing a way to manage state and coordinate actions across multiple components.

Let's explore an example where a parent component contains an intermediate component, which in turn forwards events to the appropriate child component:

<!-- App.svelte -->
<script>
  import IntermediateComponent from './IntermediateComponent.svelte';

  function handleCustomEvent(event) {
    alert(`Custom event received: ${event.detail.text}`);
  }
</script>

<IntermediateComponent on:custom-event={handleCustomEvent} />
<!-- IntermediateComponent.svelte -->
<script>
  import TargetComponent from "./TargetComponent.svelte";
</script>

<TargetComponent on:custom-event />
<!-- TargetComponent.svelte -->
<script>
  import { createEventDispatcher } from 'svelte';

  const dispatch = createEventDispatcher();

  function triggerEvent(event) {
    dispatch('custom-event', {
      text: 'Locked In!'
    });
  }
</script>

<button on:click={triggerEvent}>Trigger Event</button>

In this example:

  • TargetComponent.svelte: This component is responsible for dispatching a custom event. When clicked, the button triggers the triggerEvent function, which uses Svelte's createEventDispatcher to dispatch a custom-event.

  • IntermediateComponent.svelte: This component forwards the custom-event from the TargetComponent to its parent. It achieves this by simply including on:custom-event on the TargetComponent. This syntax ensures that any custom-event emitted by TargetComponent is re-emitted by IntermediateComponent.

  • App.svelte: The parent component listens for the custom-event emitted by the IntermediateComponent. When the event is received, it triggers the handleCustomEvent function, which displays an alert with the event's details.

DOM Event Forwarding

DOM event forwarding involves forwarding DOM events directly to parent components for handling. This feature is useful when handling browser events, such as click, mouseover, or keydown at a higher level in the component hierarchy.

Consider a scenario where a parent component listens for mouseover events on its child components to display additional information:

<!-- App.svelte -->
<script>
  import ChildComponent from "./ChildComponent.svelte";

  let text = "N/A";

  function handleMouseOver(event) {
    text = event.target.innerText;
  }
</script>

<ChildComponent on:mouseover={handleMouseOver}>Component 1</ChildComponent>
<ChildComponent on:mouseover={handleMouseOver}>Component 2</ChildComponent>
<ChildComponent on:mouseover={handleMouseOver}>Component 3</ChildComponent>
<br />
<p>Last component mouse was over: '{text}'</p>
<!-- ChildComponent.svelte -->
<div on:mouseover>
  <span><slot /></span>
</div>

<style>
  div {
    font-size: 20px;
    cursor: pointer;
    background: gray;
    margin: 5px 0;
  }
</style>

In this example, the ParentComponent listens for mouseover events on its child components using DOM event forwarding. When a mouseover event occurs on a child component, the ParentComponent handles the event and logs the text content of the child component to the console.

Bindings

Bindings in Svelte establish a connection between data in your JavaScript logic and elements in the DOM, allowing changes in one to automatically reflect in the other.

Bindings are particularly useful for form inputs and interactive elements to keep the JavaScript data and the DOM in sync. Svelte provides the bind:property directive to establish two-way bindings between variables and DOM elements.

Consider a simple example where a text input field is bound to a name:

<script>
  let name = '';
</script>

<input type="text" bind:value={name} />

<p>Hello, {name}!</p>

In the above snippet, any changes made to the text input field will automatically update the name variable, and changes to the name variable are immediately reflected in the input field and the paragraph element.

We can take this further by passing input from one component to another by binding values to component props. Let's look at an example below:

<!-- App.svelte -->
<script>
  import Greeting from "./Greeting.svelte";
  import Input from "./Input.svelte";

  let name = "";
</script>

<Input bind:value={name} />
<Greeting {name} />

In the above code:

  • We bind the name variable to the Input component through its value prop.

  • We display the name variable in the form of a greeting via the Greeting component.

Next, we need to export the value prop in the Input component and bind it to the <input> element:

<!-- Input.svelte -->
<script>
  export let value;
</script>

<input type="text" bind:value />

And with that, we can now display the name in the Greeting component:

<!-- Greeting.svelte -->
<script>
  export let name;
</script>

<p>Hello, {name}!</p>

It's worth remembering that overusing component bindings can make it difficult to track data flow within your application. Therefore, it's best practice to use component bindings sparingly.

Stores

Stores in Svelte serve as tools for managing state across your application. At their core, stores are nothing more than JavaScript objects equipped with a subscribe method. This method allows interested components or parts of your application to receive notifications whenever the value of the store changes.

Let's explore the various types of stores and how to use them effectively.

Writable Stores

A writable store is a store that allows its subscribers to read from and write to its value. In addition to having a subscribe method, a writable store also has a set and an update method for modifying its value.

Consider a scenario where you need to manage a user's authentication state across multiple components in your application. You can use a writable store to store the user's authentication status:

// stores.js
import { writable, derived } from 'svelte/store';

export const isAuthenticated = writable(false);

In this example, you have a stores.js module where you defined a writable store named isAuthenticated with a default value of false.

In order to use the store in your App properly, you need to:

  • Subscribe to the store to access its data.

  • Unsubscribe to the store if the component gets destroyed to prevent a memory leak.

  • Modify its value depending on the user's authentication state.

We can do this using the store methods (e.g. subscribe and set) provided to us:

<!-- App.svelte -->
<script>
  import { onDestroy } from 'svelte';
  import { isAuthenticated } from './stores.js';

  ...

  let auth;

  const unsubscribe = isAuthenticated.subscribe(value => {
    auth = value;
  });

  function submit() {
    // validation logic
    // ...
    isAuthenticated.set(true)
  }

  onDestroy(unsubscribe);
</script>

...

{#if auth}
  ...
{:else}
  ...
{/if}

However, this method involves setting up a lot of boilerplate code.

This is where auto-subscriptions come into play. By prefixing the store name with $, Svelte will automatically subscribe and unsubscribe to the store for you:

<!-- App.svelte -->
<script>
  import { isAuthenticated } from './stores.js';

  ...

  function submit() {
    // validation logic
    // ...
    $isAuthenticated = true
  }
</script>

...

{#if $isAuthenticated}
  ...
{:else}
  ...
{/if}

You can also modify the store value ($isAuthenticated = true) without having to use the set or update method.

Next, let's finish up the App to see the store in action:

<!-- App.svelte -->
<script>
  import { isAuthenticated } from "./stores.js"

  let username = "";

  function submit() {
    // validation logic
    // ...
    $isAuthenticated = true
  }
</script>

<nav>
  <h2>Theme Checker</h2>
</nav>

{#if $isAuthenticated}
  <p>Hi {username},</p>
  <p>The current theme is {window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'}!</p>
{:else}
  <form on:submit|preventDefault={submit}>
    <label for="username">Username</label>
    <input
      type="text"
      id="username"
      name="username"
      bind:value={username}
      required
    />

    <label for="password">Password</label>
    <input type="password" id="password" name="password" autocomplete="off" required />

    <button type="submit">Login</button>
  </form>
{/if}

Readable Stores

A readable store allows its subscribers to read its value but not modify or write to it. Unlike a writable store, a readable store doesn't expose its set or update methods to its subscribers.

From our previous example, we have an app that displays the current theme after logging in. Let's say we want to:

  • Create a store for the current theme so its value is available to other components.

  • Update the theme value whenever the user changes their color scheme (from their browser or operating system).

In this case, the components referencing the theme don't need to modify the value. We can simply update the value from 'inside' by using a readable store:

// stores.js
import { writable, readable } from 'svelte/store';

...

const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');

export const theme = readable(
  darkModeMediaQuery.matches ? 'dark' : 'light',
  (set) => {
    function setColorScheme(event) {
      const newColorScheme = event.matches ? 'dark' : 'light';
      set(newColorScheme);
    }

    darkModeMediaQuery.addEventListener('change', setColorScheme);

    return () => {
      darkModeMediaQuery.removeEventListener('change', setColorScheme);
    };
  }
);

In this example:

  • We create a MediaQueryList object darkModeMediaQuery representing the result of the media query (prefers-color-scheme: dark).

  • We set the initial state of the theme using darkModeMediaQuery.matches ? 'dark' : 'light'.

  • We add a second argument to the readable method, which exposes the set method.

  • We add a listener to darkModeMediaQuery to track changes in preference. When the preference changes, the listener callback function is called with an event containing the new state, and we set the new color scheme.

Next, let's display the current theme in our App:

<script>
  import { isAuthenticated, theme } from "./stores.js"

  let username = "";

  function submit() {
    // validation logic
    // ...
    $isAuthenticated = true
  }
</script>

...
  <p>Hi {username},</p>
  <p>The current theme is {$theme}!</p>
...

We should now have a working theme status checker!

Derived Stores

As the name implies, derived stores are stores whose values depend on or are gotten from one or more existing stores. Derived stores allow you to compute and expose derived values based on the values of other stores.

In our theme checker, suppose we want to:

  • Manually change the current theme using a toggle switch.

  • Style our theme checker app directly using --style directives.

To implement the first task, we first need to update the theme store to a writable store so we can modify its value from 'outside':

// stores.js
import { writable, derived } from 'svelte/store';

...

export const theme = writable(
  ...
);

Next, for styling our app, we can create an object for our styles:

...

const styles = {
  light: {
    background: '#ffffff',
    button: '#f9f9f9',
    slider: '#f2f7fd',
    toggle: '#a6a6a6',
    text: '#213547',
  },
  dark: {
    background: '#242424',
    button: '#1a1a1a',
    slider: '#383838',
    toggle: '#737373',
    text: 'rgba(255, 255, 255, 0.87)',
  },
};

The object contains all the styles for our UI elements for the light and dark themes. Since we only need the styles for the current theme at any point in time, we can create a derived store to store only the current theme styles:

...

export const themeStyles = derived(theme, ($theme) => styles[$theme]);

The derived function, in its simplest form, works by taking two arguments:

  • The first is a single store we want to derive our store value from, which is the theme store in our case.

  • The second argument is a callback function that returns the derived value (styles[$theme]).

With the stores in place, let's apply the necessary changes in the app to get our toggle switch and new styling:

<!-- App.svelte -->
<script>
  import { isAuthenticated, theme, themeStyles } from "./stores.js";

  ...

  function onCheckboxChange(event) {
    let checked = event.target.checked;
    $theme = checked ? "dark" : "light";
  }

  $: checked = $theme === "dark";
  $: ({ background, button, slider, toggle, text } = $themeStyles);
</script>

<div class="container" style:--background={background} style:--text={text}>
  <nav>
    <h2>Theme Checker</h2>
    {#if $isAuthenticated}
      <label class="switch">
        <input
          type="checkbox"
          bind:checked
          on:change={onCheckboxChange}
        />
        <span
          class="slider"
          style:--slider={slider}
          style:--toggle={toggle}
        ></span>
      </label>
    {/if}
  </nav>

  {#if $isAuthenticated}
    ...
    <p>The current theme is {$theme}!</p>
  {:else}
    ...
    <button type="submit" style:--button={button}>Login</button>
    ...
  {/if}
</div>

Custom Stores

Custom stores provide flexibility in creating stores with custom logic. You can define custom stores by creating an object with the subscribe method, along with any additional methods or properties you need.

Suppose we want to implement a global notification system in our application. We can use a custom store to manage the notification state and provide methods for adding and removing notifications:

// stores.js
import { writable, derived } from 'svelte/store';

...

function createNotifications() {
  const { subscribe, set, update } = writable([]);

  return {
    subscribe,
    addNotification: (payload) =>
      update((notifications) => [...notifications, payload]),
    removeNotification: (payload) =>
      update((notifications) =>
        notifications.filter((notification) => notification.id !== payload)
      ),
    clearNotifications: () => {
      set([]);
    },
  };
}

export const notifications = createNotifications();

The createNotifications function returns a custom store with methods for adding, removing, and clearing notifications.

Next, let's import the function into our app and utilize its methods to manage notifications:

<!-- App.svelte -->
<script>
  import {
    isAuthenticated,
    theme,
    themeStyles,
    notifications,
  } from "./stores.js";

  ...

  function submit() {
    // validation logic
    // ...
    $isAuthenticated = true;
    notifications.addNotification({
      id: new Date().getTime(),
      content: `User logged in at ${new Date().toLocaleTimeString()}!`,
    });
  }

  function onCheckboxChange(event) {
    ...

    notifications.addNotification({
      id: new Date().getTime(),
      content: `Changed theme to ${$theme} at ${new Date().toLocaleTimeString()}!`,
    });
  }

  ...
</script>

...
  {#if $isAuthenticated}
    <p>Hi {username},</p>
    <p>The current theme is {$theme}!</p>
    <br />
    <h4>
      Notifications 
      <button
        on:click={() => notifications.clearNotifications()}
        style:margin-left={"10px"}
        style:--button={button}
      >
        Clear
      </button>
    </h4>
    <ul>
      {#each $notifications as notification}
        <li>
          <div>
            <span>{notification.content}</span>
            <button
              style:margin-left={"10px"}
              style:--button={button}
              on:click={() =>
                notifications.removeNotification(
                  notification.id
                )}
             >
                Remove
             </button>
           </div>
          </li>
          <br />
        {/each}
    </ul>
...

In the above code, the notifications store is used to manage a list of notifications. Notifications are added when the user logs in and when the theme is changed. Users can also clear all notifications or remove individual ones.

Context API

The Context API in Svelte allows you to pass data deeply through the component tree without having to pass props at every level. This is particularly useful for shared state or services.

Suppose you have an application where multiple child components need access to a user profile and a messaging service. You can use the Context API to provide these shared resources.

Firstly, let's define two stores for the user profile and messaging service in a services.js module:

// services.js
import { writable } from 'svelte/store';

export const userProfile = writable({
  name: 'John Doe',
  email: 'john.doe@example.com',
});

export function sendMessage(message) {
  alert(`Message sent: ${message}`);
}

Next, we can access the stores in the parent component by resources using the setContext function:

<!-- App.svelte -->
<script>
  import { setContext } from 'svelte';
  import Child from './Child.svelte';
  import { userProfile, sendMessage } from './services.js';

  setContext('userProfile', userProfile);
  setContext('sendMessage', sendMessage);
</script>

<Child />

To access this context in the child component, we can use the getContext function:

<!-- Child.svelte -->
<script>
  import { getContext, onMount } from 'svelte';

  const userProfile = getContext('userProfile');
  const sendMessage = getContext('sendMessage');

  let message = '';

  function handleSend() {
    sendMessage(message);
    message = '';
  }
</script>

<p>User: {$userProfile.name}</p>
<p>Email: {$userProfile.email}</p>

<input type="text" bind:value={message} placeholder="Type a message" />
<button on:click={handleSend}>Send</button>

Components can also update the shared state gotten via context. Let's extend our example to allow the child component to update the user's email:

<!-- Child.svelte -->
<script>
  ...

  let message = '';
  let newEmail = '';

  function handleSend() {
    sendMessage(message);
    message = '';
  }

  function updateEmail() {
    $userProfile = { ...$userProfile, email: newEmail };
    newEmail = '';
  }
</script>

...

<input type="text" bind:value={message} placeholder="Type a message" />
<button on:click={handleSend}>Send</button>

<input type="email" bind:value={newEmail} placeholder="Update email" />
<button on:click={updateEmail}>Update Email</button>

In this extended example, the child component can update the user's email using the update method on the userProfile store.

Module Context

Module context in Svelte allows you to define code that runs once per module, regardless of how many times the component is instantiated. This is useful for initializing shared resources or performing setup tasks that should only happen once.

Let's consider a simpler example where we need to initialize a counter that increments every time a new instance of a component is created. This counter should maintain a global count across all instances of the component.

First, set up the global counter in the module context:

<!-- Component.svelte -->
<script context="module">
  let globalCounter = 0;

  export function incrementGlobalCounter() {
    globalCounter += 1;
    return globalCounter;
  }
</script>

<script>
  import { incrementGlobalCounter } from './YourComponent.svelte';

  let instanceCounter = incrementGlobalCounter();
</script>

<p>This is instance number {instanceCounter} of this component.</p>

The module context block defines a global globalCounter variable and an incrementGlobalCounter function that increments and returns the counter value. This ensures that every time the component is instantiated, it gets a unique instance number.

Now, whenever the component is instantiated, it will display its unique instance number based on the global counter:

<!-- App.svelte -->
<script>
  import Component from './Component.svelte';
</script>

<Component />
<Component />
<Component />

Conclusion

In this guide, we've explored the key aspects of state management in Svelte and how you can effectively use them in your applications. By mastering these concepts, you can ensure that your application state is predictable and easy to manage.

For further reading on any of the concepts outlined in this article, you can go through the Svelte documentation.