Persisting State with LocalStorage
Learning Objectives
- You know how to persist data with localStorage.
- You know how to use localStorage with shared state in Svelte.
So far, the applications that we have constructed have featured functionality for adding, removing, and listing items. These applications have used shared state to manage the data, which has worked well for sharing data between components. However, the state has only been stored in memory, which means that any changes made to the state are lost when the page is reloaded.
Browsers and localStorage
Browsers have a localStorage object that can be used to persist data so that it is available even after the page is reloaded. Data is stored in local storage as key-value pairs, where both the key and the value are strings.
The localStorage object implements the Storage interface. It provides the method setItem(key, value) for storing data, getItem(key) for retrieving data, and removeItem(key) for deleting data.
In the simplest form, local storage can be used as follows (given the application is running in the browser).
const KEY = "key";
const value = 10;
localStorage.setItem(KEY, value);
const retrievedValue = localStorage.getItem(KEY);
console.log(retrievedValue + 10);
The above would log 1010 to the console. This is because the value is stored as a string, and when concatenating two strings, they are joined together. To perform numerical addition, we would need to convert the retrieved value to a number using the parseInt function.
Svelte and localStorage
Using localStorage with Svelte does not differ from using localStorage in plain JavaScript. The main difference is that we need to ensure that we are running the code in a browser environment, as localStorage is not available in non-browser environments, such as during server-side rendering.
SvelteKit applications can be rendered both on the server and in the browser. When rendering on the server, certain browser-specific APIs, such as localStorage, are not available. Therefore, it is important to check whether the code is running in a browser environment before accessing such APIs.
In Svelte, we can look whether the code is running in a browser environment by checking the browser variable exported by Svelte through $app/environment. The browser variable is a boolean that is true when the code is running in a browser environment.
As an example, the following demonstrates a component that keeps track of a count and persists the count in local storage. The count is loaded from local storage when the component is initialized, and the count is saved to local storage whenever it changes.
<script>
import { browser } from "$app/environment";
const COUNT_KEY = "count";
let count = $state(0);
// loading a count from the local storage if it exists
if (browser && localStorage.getItem(COUNT_KEY) !== null) {
count = parseInt(localStorage.getItem(COUNT_KEY));
}
const incrementCount = () => {
count++;
localStorage.setItem(COUNT_KEY, count);
};
</script>
<h1>Count: {count}</h1>
<button onclick={incrementCount}>Increment</button>
When you try the above component locally, you’ll notice that the count is persisted even after reloading the page. Furthermore, the count flashes briefly at 0 before being updated to the correct value from local storage. This is because the initial value of count is set to 0, and then it is updated to the value from local storage after the component is initialized.
Storing more complex data locally
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 shows how localStorage would be used to store a JavaScript object.
const KEY = "key";
const value = {
id: 1,
title: "Title",
};
localStorage.setItem(KEY, JSON.stringify(value));
const retrievedValue = JSON.parse(localStorage.getItem(KEY));
console.log(retrievedValue);
Shared state and localStorage
When using shared state, we can integrate local storage to the shared state object, which allows storing the data across page reloads. This can be done by loading the initial state from local storage when the shared state is created, and saving the state to local storage whenever it changes.
Books example
As an example, let’s consider our earlier bookState.svelte.js shared state, which looks as follows.
let bookState = $state([
{ id: 1, name: "HTML for Hamsters" },
{ id: 2, name: "CSS: Cannot Style Sandwiches" },
{ id: 3, name: "JavaScript and the Fifty Shades of Errors" },
]);
const useBookState = () => {
return {
get books() {
return bookState;
},
getOne: (id) => {
return bookState.find((b) => b.id === id);
},
addBook: (name) => {
bookState.push({ id: bookState.length + 1, name });
},
};
};
export { useBookState };
To integrate local storage, we need to load the initial state from local storage when creating the bookState, and we need to save the state to local storage whenever it changes.
This would be done as follows.
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: (name) => {
bookState.push({ id: bookState.length + 1, name });
saveBooks();
},
};
};
export { useBookState };
Now, if we plug the above shared state into our application, the books would be persisted in local storage, and the books would be loaded from local storage when the application is started. However, when we attempt to open the page for an individual book, the application crashes.
Fixing issues
Our existing Book.svelte is 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>
<h1>{book.name}</h1>
<ChapterList {bookId} />
The application crashes because the bookState is initially empty, and the getOne function returns undefined when trying to find a book by its id. This happens as SvelteKit first renders the page on the server, where local storage is not available, and then re-renders the page in the browser, where local storage is available.
During the initial render on the server, the
bookStateis empty, and thusgetOnereturnsundefined. When Svelte tries to access thenameproperty ofundefined, it results in an error.
To fix this, we can add a check to ensure that the book exists before trying to access its properties. The following outlines the modified Book.svelte file.
<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>
<h1>{book ? book.name : "Loading..."}</h1>
<ChapterList {bookId} />
Now, when the data has not yet been loaded from localStorage, the page shows the text “Loading…”. Then, once the localStorage has been loaded and the book is available, the page shows the name of the book.
Chapters example
The same holds for the chapter state that we had earlier, which looked as follows. The example also shows the functionality for adding a chapter, which we worked on in an exercise.
let chapterState = $state({
1: [
{ id: 1, name: "Hamster Homes" },
{ id: 2, name: "Tiny Tables" },
{ id: 3, name: "Forms & Seeds" },
],
2: [
{ id: 1, name: "Styling Bread" },
{ id: 2, name: "Decorating Lettuce" },
{ id: 3, name: "Advanced Pickles" },
{ id: 4, name: "Garnish Mastery" },
],
3: [
{ id: 1, name: "Oops 101" },
{ id: 2, name: "Many Errors" },
{ id: 3, name: "Fifty More Bugs" },
],
});
const useChapterState = () => {
return {
get chapters() {
return chapterState;
},
addChapter: (bookId, name) => {
if (!chapterState[bookId]) {
chapterState[bookId] = [];
}
chapterState[bookId].push({
id: chapterState[bookId].length + 1,
name,
});
},
};
};
export { useChapterState };
To integrate local storage, we would do something similar to what we did for the books. We would load the initial state from local storage when creating the chapterState, and we would save the state to local storage whenever it changes.
import { browser } from "$app/environment";
const CHAPTERS_KEY = "chapters";
let initialChapters = {};
if (browser && localStorage.getItem(CHAPTERS_KEY) != null) {
initialChapters = JSON.parse(localStorage.getItem(CHAPTERS_KEY));
}
let chapterState = $state(initialChapters);
const saveChapters = () => {
localStorage.setItem(CHAPTERS_KEY, JSON.stringify(chapterState));
};
const useChapterState = () => {
return {
get chapters() {
return chapterState;
},
addChapter: (bookId, name) => {
if (!chapterState[bookId]) {
chapterState[bookId] = [];
}
chapterState[bookId].push({
id: chapterState[bookId].length + 1,
name,
});
saveChapters();
},
};
};
export { useChapterState };
Now, our application would load the chapters from local storage at the start, and would provide a function for adding chapters that also saves the chapters to local storage.
Almost as simple as that
There are a few caveats, however. At the moment, the Chapter.svelte file looks as follows.
<script>
let { bookId, chapterId } = $props();
import { useBookState } from "$lib/states/bookState.svelte.js";
import { useChapterState } from "$lib/states/chapterState.svelte.js";
let bookState = useBookState();
let chapterState = useChapterState();
let book = bookState.getOne(bookId);
let chapter = chapterState.chapters[bookId].find((c) => c.id === chapterId);
</script>
<h1>{book.name}</h1>
<h2>{chapter.name}</h2>
<p>This is chapter {chapter.id} of book {book.id}.</p>
There are multiple cases where the application would crash — you can try this by navigating to a chapter page and then pressing reload.
The key culprits are both the bookState and chapterState, which are initially empty. Due to this, both bookState.getOne(bookId) and chapterState.chapters[bookId] will return undefined. Accessing methods or properties of undefined results in an error.
We can fix this, but it is a bit more involved than with the Book.svelte file. This is because we need to ensure that both the book and the chapter exist before trying to access their properties. If either of them does not exist, we cannot show any information about the chapter. First, we need to modify retrieving the chapter to ensure that we do not try to access the find method of undefined.
let chapter = chapterState.chapters[bookId]?.find((c) => c.id === chapterId);
The ? after chapterState.chapters[bookId] is the optional chaining operator. It ensures that if chapterState.chapters[bookId] is undefined, the expression will short-circuit and return undefined instead of trying to call the find method on undefined.
Now, we need to ensure that both book and chapter exist before trying to access their properties. If either of them does not exist, we can show a loading message instead.
<h1>{book ? book.name : "Loading..."}</h1>
{#if chapter}
<h2>{chapter.name}</h2>
<p>This is chapter {chapter.id} of book {book.id}.</p>
{:else}
<p>Loading...</p>
{/if}
Jointly, the component would look as follows.
<script>
let { bookId, chapterId } = $props();
import { useBookState } from "$lib/states/bookState.svelte.js";
import { useChapterState } from "$lib/states/chapterState.svelte.js";
let bookState = useBookState();
let chapterState = useChapterState();
let book = bookState.getOne(bookId);
let chapter = chapterState.chapters[bookId]?.find((c) => c.id === chapterId);
</script>
<h1>{book ? book.name : "Loading..."}</h1>
{#if chapter}
<h2>{chapter.name}</h2>
<p>This is chapter {chapter.id} of book {book.id}.</p>
{:else}
<p>Loading...</p>
{/if}
Now, the application loads initially the text Loading... until both the book and chapter are available, at which point it shows the relevant information.
Summary
In summary:
- Browsers provide a localStorage object that can be used to persist data across page reloads.
- Data is stored in local storage as key-value pairs, where both the key and the value are strings.
- When working with more complex data, such as arrays, maps, or objects, we need to convert the data to a string using JSON.stringify before storing it to local storage, and convert it back to the original format using JSON.parse when retrieving it.
- When using localStorage in Svelte, we need to ensure that the code is running in a browser environment by checking the browser variable exported by Svelte through $app/environment.
- When using shared state, we can integrate local storage to the shared state object, which allows storing the data across page reloads.