Client-Side Development

Sharing State Between Components


Learning Objectives

  • You know how to separate state from components.
  • You know how to store and retrieve data using localstorage.

So far, when looking into reactivity and state, the reactive variables have been declared in one of the Svelte components, similar to e.g. the following component.

<script>
  let count = $state(0);
</script>

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

When multiple components need access to the state, such as was the case in our earlier todo example, the state needs to be shared between the components. Previously, we passed the state as a property from the parent component to the child component, and — when this failed — passed a function to the child component that updated the state in the parent component.

It is also possible to separate the state from the components. This way, the state is not tied to a specific component, and the state can be shared between multiple components.

Separate state from components

Let’s take a look at how we would create functionality similar to the above, but wichout declaring the state within the component. For this, create a folder called states to the lib folder within the src folder of the project.

Svelte has classically used the term “store” to represent objects that store state outside of components. We’ll avoid the term “store” in these materials, as if you Google for the term Svelte stores, you’ll easily end up with outdated documentation and examples.

Inside the states folder, we create a file called countState.svelte.js and add the following code to it:

const useCountState = () => {
  let countState = $state(0);

  return {
    get count() {
      return countState;
    },
    increment: () => countState++,
  };
};

export { useCountState };

The above outlines a function called useCountState that returns an object with two properties: count and increment. The count property is a getter that returns the value of the countState variable. The increment property is a function that increments the countState variable.

Suffix ".svelte.js"

It is crucial to use the suffix .svelte.js. With the suffix, Svelte will know to compile the file as a Svelte component and the function $state will be available for use within the file.

Now, with the above component, we can adjust our earlier component to use the new count state. In the following, instead of creating the state within the component, we import the state object from the countState.svelte.js file, and use the state object to keep track of the count.

The concrete count can be accessed through the getter count — although getters are functions, they are used as properties. The increment function is used to increment the count.

<script>
  import { useCountState } from "$lib/states/countState.svelte.js";
  const countState = useCountState();
</script>

<p>Count is {countState.count}</p>
<button onclick={() => countState.increment()}>Increment</button>

Now, the component continues to work as before, but the state is separated from the component.

Sharing state between components

In the above implementation of the count state object, a new state is created for each component that uses the state object. This is due to the state being initiated within the function that returns the state object. That is, in the countState.svelte.js, the state is initiated on the first row of the function useCountState.

const useCountState = () => {
  let countState = $state(0);

  return {
    get count() {
      return countState;
    },
    increment: () => countState++,
  };
};

export { useCountState };

This means that each component that uses the state has its own state object, and the state is not shared between components. To share the state between components, we need to move the state outside of the function that returns the state object. This way, the state is shared between all components that use the state object. For the above, this would mean moving the line let countState = $state(0); outside of the function useCountState.

let countState = $state(0);

const useCountState = () => {
  return {
    get count() {
      return countState;
    },
    increment: () => countState++,
  };
};

export { useCountState };

Now, we could have another component that uses the same state object as the previous component.

<script>
  import { useCountState } from "$lib/states/countState.svelte.js";
  const counter = useCountState();
</script>

<p>Count multiplied by two is {counter.count * 2}</p>

Now, if the two components, the one above and the one that is used to increment the count, are used on the same page, incrementing the count also updates the count in the other component.

Storing state locally

The state, like any variables of a client-side web application, is stored in the memory of the browser. When a page is loaded, the memory is initiated. Similarly, when a page is reloaded, the memory is initiated, and we lose any changes to the state. To continue from the previous state on page reload, the state needs to be stored in a way that it persists across page loads.

For this, we can use the localStorage object offered by browsers. The data stored in the local storage is available even after the page is reloaded.

The localStorage object implements the Storage interface. It provides the method setItem(key, value) for storing data, getItem(key) for retrieving data, removeItem(key) for deleting data, and hasOwnProperty(key) for checking whether a specific entry exists.

Data in local storage is stored as strings. This means that when storing data other than strings, the data needs to be converted to string format before storing. Similarly, when retrieving data, the data needs to be converted back to the original format. Some data will be automatically converted into string format when using the setItem method. For example, numbers will be stored as strings.

Let’s first look into storing the count state to the local storage.

The following outlines a component for sharing count data across components. Whenever the count is incremented, the count is updated in all of the components that use the count. The count starts from zero when the page is loaded.

let countState = $state(0);

const useCountState = () => {
  return {
    get count() {
      return countState;
    },
    increment: () => countState++,
  };
};

export { useCountState };

To store the count to the local storage, we can use the localStorage object. For the above example, we would first load the initial count from local storage (if such count exists) and then store the count to the local storage whenever the count is incremented.

We also need to check whether we are running the code in a browser environment, which comes with localstorage. This can be done by checking the browser variable exported by Svelte through “$env/environment”. The browser variable is a boolean that is true when the code is running in a browser environment.

The following outlines the full functionality for storing the count to the local storage.

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

const COUNT_KEY = "count";
let initialCount = 0;
if (browser && localStorage.hasOwnProperty(COUNT_KEY)) {
  initialCount = parseInt(localStorage.getItem(COUNT_KEY));
}

let countState = $state(initialCount);

const useCountState = () => {
  return {
    get count() {
      return countState;
    },
    increment: () => {
      countState++;
      localStorage.setItem(COUNT_KEY, countState);
    },
  };
};

export { useCountState };

The key changes when compared with the earlier code are in the beginning of the file and in saving the count when it changes. We first import the browser variable from the $app/environment, which allows us to check whether the application is running in the browser environment. We then define a constant COUNT_KEY that is used as the key for storing the count to the local storage, and introduce a variable that stores an initial count.

This is followed by concretely checking if the code is running in the browser environment, jointly with checking whether the local storage has the key COUNT_KEY. If we are in the browser environment and the key exists, we load the value found using the key to the initial count. After this, the concrete state is initiated with the initial count.

As values are stored in local storage as strings, we also parse the value to an integer when loading the value from local storage using the parseInt function.

Server-side rendering

When you use the above code, you might notice that the count is very briefly 0 when the page is loaded. After this, the count is updated to match the correct value.

This behavior stems from Svelte rendering the page initially on the server. When the page is loaded, the initial value of the count is 0. After the page is loaded, the client-side JavaScript is initiated, and the count is updated to the correct value. To disable server-side rendering, you can add a file called +layout.server.js to the routes folder (in the src folder) and place the following line to it.

export const ssr = false;

Loading Exercise...

More complex data

We can also work with other data types in a similar fashion to the above. As an example, we could take our earlier todo example, and separate the state from the components. The following outlines a starting point for storing todo items in an array, adding items to the array, removing items from the array, and for changing the done state of an item.

let todoState = $state([]);

const useTodoState = () => {
  return {
    get todos() {
      return todoState;
    },
    add: (todo) => {
      todoState.push(todo);
    },
    changeDone: (id) => {
      const todo = todoState.find((todo) => todo.id === id);
      todo.done = !todo.done;
    },
    remove: (id) => {
      todoState = todoState.filter((todo) => todo.id !== id);
    },
  };
};

export { useTodoState };

Create a file called todoState.svelte.js to the folder states within the lib folder of the project, and place the above code to the file.

Taking the above state functionality into use in Todos.svelte, TodoForm.svelte, and TodoItem.svelte would require modifying all of the components.

First, in TodoForm.svelte, we would need to import the useTodoState function from todoState.svelte.js and use the state object to add new todos. This would look as follows.

<script>
  import { useTodoState } from "$lib/states/todoState.svelte.js";
  let todoState = useTodoState();

  const addTodo = (e) => {
    const todo = Object.fromEntries(new FormData(e.target));
    todo.id = crypto.randomUUID();
    todoState.add(todo);
    e.target.reset();
    e.preventDefault();
  };
</script>

<form onsubmit={addTodo}>
  <label for="name">Todo</label>
  <input id="name" name="name" type="text" placeholder="Enter a new todo" />
  <div>
    <input id="done" name="done" type="checkbox" />
    <label for="done">Done</label>
  </div>
  <input type="submit" value="Add Todo" />
</form>

For the TodoItem.svelte component, we would similarly need to import the useTodoState function and to create the local todoState variable. We no longer would, however, need to take the removeTodo function as a property, as the state object now has the remove function. Similarly, we could change the functionality for setting a todo as done, as the state object now has the changeDone function.

After modifications, the file would look as follows.

<script>
  import { useTodoState } from "$lib/states/todoState.svelte.js";
  let todoState = useTodoState();

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

<input
  type="checkbox"
  onchange={() => todoState.changeDone(todo.id)}
  id={todo.id}
/>
<label for={todo.id}>
  {todo.name} ({todo.done ? "done" : "not done"})
</label>
<button onclick={() => todoState.remove(todo.id)}>Remove</button>

Finally, for the Todos.svelte component, we would similarly need to import the useTodoState function from todoState.svelte.js and use the state object to list the todos. This would look as follows.

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

  import TodoForm from "./TodoForm.svelte";
  import TodoItem from "./TodoItem.svelte";

  let todoState = useTodoState();
</script>

<h1>Todos</h1>

<h2>Add Todo</h2>

<TodoForm />

<h2>Existing todos</h2>

<ul>
  {#each todoState.todos as todo}
    <li>
      <TodoItem {todo} />
    </li>
  {/each}
</ul>
Pattern for separating state

Note the pattern in the code used for separating state. The state object is created as a function that returns an object with properties for accessing and updating the state. Importantly, the concrete state is accessed through a getter function. When a component uses the state object, it imports the state object by calling the function that creates the state object.

Storing more complex data

When working with more complex data, such as arrays, maps, or objects, we need to convert the data to a string before storing it to the local storage. The data can be converted to a string using the JSON.stringify function. When retrieving the data, the data needs to be converted back to the original format. This can be done using the JSON.parse function.

The following demonstrates using the JSON functions for storing and retrieving an array of todos to the local storage. Whenever a new todo is added to the todo storage, the available todos are stored to local storage, from which they will be loaded the next time the page is loaded.

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

const TODOS_KEY = "todos";
let initialTodos = [];
if (browser && localStorage.hasOwnProperty(TODOS_KEY)) {
  initialTodos = JSON.parse(localStorage.getItem(TODOS_KEY));
}

let todoState = $state(initialTodos);

const saveTodos = () => {
  localStorage.setItem(TODOS_KEY, JSON.stringify(todoState));
};

const useTodoState = () => {
  return {
    get todos() {
      return todoState;
    },
    add: (todo) => {
      todoState.push(todo);
      saveTodos();
    },
    changeDone: (id) => {
      const todo = todoState.find((todo) => todo.id === id);
      todo.done = !todo.done;
      saveTodos();
    },
    remove: (id) => {
      todoState = todoState.filter((todo) => todo.id !== id);
      saveTodos();
    },
  };
};

export { useTodoState };

Now, the application would store the todos to the local storage, and the todos would be loaded from the local storage when the page is loaded.

Pattern for storing state locally

Note the pattern in the code used for storing state locally. First, a variable holding the default (often empty) value is created. Then, the local storage is checked — if the relevant data exists in the local storage, the data is loaded and set as the value of the variable. After this, the variable is used to create the state object. Similarly, when the state is updated, the local storage is updated with the new value of the state.


Loading Exercise...