How to persist user preferences in Svelte (w/o DB)

RMAG news

Context

I was in the middle of creating a static site with Svelte. Now of course you might say, why not just make it with plain HTML and CSS. To that I say, it’s 2024 and you can go to jail for something like that.
Now, I’m a React developer by trade, but I chose Svelte for this project, because 1) it ships less JS to the browser due to it not using a virtual DOM, 2) it’s excellent for static site generation (SSG), which is what I needed for this project, and most importantly 3) it was time I finally learned it and saw what the craze was about, I can’t go a day without hearing about the amazing developer experience Svelte provides. And by golly was I in for a treat! Seriously everyone needs to learn Svelte.

Challenge

I needed to provide the website content in different locales, and I wanted to persist their preference across all time and space; all pages, routes, and sessions. This challenge is also applicable to website theming, and potentially any other user preference setting that isn’t saved on serverside.

First step is setting and viewing the user preference.

Here’s how:

// routes/+page.svelte
<script>
// defaults to ‘en’;
let locale = en;
</script>

<h1>Your preference of locale is set to {locale}</h1>
<button on:click={() => locale = en}>english</button>
<button on:click={() => locale = es}>spanish</button>
<button on:click={() => locale = fr}>french</button>

This is all good and well for a page, but if we wanna share it between pages.

Enter, Svelte +layout.svelte. If we move that code to the root layout, every descendant of that route (page.svelte and layout.svelte alike) will have that layout display without changing. If I navigate between routes as long as I don’t move up above that layout, the preference will stay.

Now the next issue is accessing that preference from a different component/page. To do that we will need a store. Which is just an object with a subscribe function. Whatever subscribes to that store will update when the store updates.

Here’s a brief review of how to create a store in Svelte.

// $lib/utils/stores.js
import { writable } from svelte/store;
const defaultLocale = en;
export const locale = writable(defaultLocale);

The locale selector is located inside the navbar, which I want to be nearly global to this site so it will live in +layout.svelte
Here’s how to use that store, in a dumbed down version of my use case.

// routes/+layout.svelte
<script>
import { locale } from $lib/utils/stores.js;
</script>

<h1>Your preference of locale is set to {$locale}</h1>
<button on:click={() => $locale = en}>english</button>
<button on:click={() => $locale = es}>spanish</button>
<button on:click={() => $locale = fr}>french</button>

The layout file is included inside every page and layout that’s under it. So the state of locale will persist across page/route navigation. but it will reset upon refresh and or closing and reopening the site.

So naturally we will want to persist the state somewhere. Let’s try local storage.
We’re going to store our preferences store in localStorage
We can reuse the code from above and subscribe the localStorage to the store. So updates to store also update localStorage

// $lib/utils/stores.js
import { browser } from $app/environment;
import { writable } from svelte/store;
const defaultLocale = en;
// retrieve localStorage value if it’s been set already
// important for persisting through refreshes
// broswer check is needed because localstorage doesn’t exist on server side
let storedLocale = browser && localStorage.getItem(locale) || defaultLocale;
export const locale = writable(storedLocale);
// subscribe to changes
locale.subscribe((val) => browser && localStorage.setItem(locale, val));
// routes/+layout.svelte
<script>
import { locale } from $lib/utils/stores.js;
</script>

<h1>Your preference of locale is set to {$locale}</h1>
<button on:click={() => $locale = en}>english</button>
<button on:click={() => $locale = es}>spanish</button>
<button on:click={() => $locale = fr}>french</button>

Here’s what we’ve achieved so far:

✅ get & set a preference
✅ persist it through pages/routes
✅ persist through refreshes AND sessions (new tab/window)

But what’s this?? there’s a new problem. Everytime you reload the page, there’s a layout shift, where the value of the preference will be set to its initial default before it fetches the stored localstorage value.

This happens because of server side rendering (SSR), which is an amazing amazing feature that’s readily available to us today. It works by partially rendering the website on server side before sending it to the client, and then hydrating it with the rest of the javascript that’s necessary for it to fully render, clientside JS basically. I won’t go into details of why and how SSR is good, you know how to google.

The problem is that, our user preferences are stored in localstorage, which is only available on client side. The server that’s performing SSR is unaware of this localstorage. So when it partially renders the page, localStorage.getItem() just returns undefined. So it will default to the fallback value of “en”. Even though the users might have specified otherwise in their previous visit, and stored that preference in their localStorage. There are 2 ways to solve this

1) We can add conditional rendering for any affected content on the site so that it doesn’t render until our hydration is fully complete, but that will defeat the purpose of SSR.
2) We can use cookies 🍪 to store user preferences. Because unlike localStorage, cookies persist on user’s system and the server will have access to them. Perfect!

P.S. we could also try and fetch localstorage value first thing in our lifecycle events like onMount, but that doesn’t follow the Svelte way. It doesn’t fully embrace SSR as we are interrupting the initial load with dataloading/fetching, which should be done on server side as much as possible.

If we were in React land, then it’d be more acceptable.

To send and receive the preference cookies, we will need to setup a server js file for our layout that will handle the data fetching and initial data setting through Svelte’s load function. That data is then loaded into our route, as defined in +layout.svelte.

For this, I’ll move most of the store logic to +layout.svelte

// $lib/utils/stores.js
import { writable } from svelte/store;
export const locale = writable();
// routes/+layout.server.js
import data from $lib/data/en.json;
export function load({ cookies }) {
let defaultLocale = en;
data.preferences = { locale: cookies.get(locale) || defaultLocale };
return data;
}
<script>
// routes/+layout.svelte
import { locale } from $lib/utils/stores.js;
import { browser } from $app/environment;
import {setCookie} from $lib/utils/cookies.js;
export let data;
// use the cookie value
$locale = data.preferences.locale;
if(browser){
let storedLocale = localStorage.getItem(locale);
// prefer localstorage value if cookie is unreliable for any reason.
$locale = storedLocale || $locale;
}
// subscribe localstorage and cookies to the locale store
$: {
browser && localStorage.setItem(locale,$locale);
// setCookie is a custom function I wrote for managing cookies easier, you can find it under this codeblock
browser && setCookie(locale,$locale);
}
</script>

<h1>Your preference of locale is set to {$locale}</h1>
<button on:click={() => $locale = en}>english</button>
<button on:click={() => $locale = es}>spanish</button>
<button on:click={() => $locale = fr}>french</button>
// $lib/utils/cookies.js
import { browser } from $app/environment;

export function setCookie(name, value, path = /) {
if (!browser)
throw new Error(
setCookie can only be used in the browser, make sure to check for browser object first
);
const currentDate = new Date();
const farFutureDate = new Date(
currentDate.getTime() + 100 * 365 * 24 * 60 * 60 * 1000
); // 100 years from now
const expires = farFutureDate.toUTCString();
document.cookie = `${name}=${value}; expires=${expires}; path=${path}`;
}

At this point you might be wondering why do we need localstorage if we are going to use cookies. The answer is redundancy.

And there you have it! This was how to persist data on clientside and avoid layout shifts in SSR.

If you have any comments, questions, or concerns, please keep them to yourself.
All typos are on purpose.

Resources

SvelteKit Docs
Unpaid Intern
Emotional Support
Shameless Plug

Cover picture provided by unsplash

Leave a Reply

Your email address will not be published. Required fields are marked *