52
Per-user global stores in Sveltekit
Svelte is really nice with stores. You can have a separate folder like this with named global stores:
// stores/user.ts
import { writable } from "svelte/store";
export const user = writable<User | null>(null);
// stores/products.ts
import { writable, get as $ } from "svelte/store";
import { user } from "../user";
export const products = writable<Product[] | null>(null);
// Remove products on log out
user.subscribe($user => {
if (!$user) {
products.set(null);
}
});
export async function loadProducts() {
// Simple caching mechanism
if (!$(products) && $(user)) {
const response = await fetch('/api/products');
products.set(await response.json());
}
}
And then you can directly import the stores in your components, no hassle!
On SvelteKit, though it doesn't work. Or rather, it works, but the stores are shared among all users:
Mutating any shared state on the server will affect all clients, not just the current one.
So... how do we get around this?
It already exists! Sort of...
It's the session, available through input.session
in a load(input)
function, or through getStores().session
within a component.
But the session can only contain POJO data, or JSON with Maps and Sets. It's the limits imposed by devalue which is used by SvelteKit.
So, no dice for putting the store inside the session. But there's a nifty feature of javascript called a WeakMap.
So you can do something like this:
const stores = new WeakMap<Session, ...>();
And to get the stores associated with the current user, you can do either stores.get(input.session)
when in a load
function or stores.get($session)
when in a component.
Wouldn't a standardized way of accessing the stores be great?
First, we need an easy way to get the current session, no matter where we are.
It's easy when in a component: we can just call getStores()
. Inside a load
function, there's no built-in way, however.
So, we're going to need a constraint: create a useLoad
function, that is called during load
:
export function load(input) {
useLoad(input, func1, func2, ...);
}
It will execute func1
, func2
and so on with the session in input.session
as the context.
And then we will create useSession()
that can get the current session from anywhere.
// useLoad.ts
import type { LoadInput } from "@sveltejs/kit";
type SessionData = { stores: Map<unknown, unknown>; fetch: typeof fetch };
export const loadSession = writable<Session | null>(null);
export const sessionData = new WeakMap<Session, SessionData>();
// Typescript magic
type ReturnTypes<T> = T extends [...infer U, infer A]
? A extends (...args: unknown[]) => unknown
? ReturnType<A> & ReturnTypes<U>
: void
: void;
export function useLoad<T extends Array<(...args: unknown[]) => unknown>>(input: LoadInput, ...fns: T): ReturnTypes<T> {
if (!sessionData.has(input.session)) {
sessionData.set(input.session, { stores: new Map(), fetch: input.fetch });
}
loadSession.set(input.session);
try {
return Object.assign({}, ...fns.map((fn) => fn()));
} finally {
loadSession.set(null);
}
}
// useSession.ts
import { getStores } from "$app/stores";
import { get as $, writable } from "svelte/store";
import { loadSession, sessionData } from "../useLoad";
export function useSession(): { session: Session; data: SessionData } {
const session = $(loadSession) ?? ($(getStores().session) as Session);
const data = sessionData.get(session);
if (!data) {
throw new Error("Call useLoad before calls to useSession");
}
return {
session,
data,
};
}
Now we have:
-
useLoad(input, ...)
that needs to be called inside the load function. -
useSession()
which can get the current session, even within the load function.
It's inspired by the excellent Vue libary pinia
Svelte has a wonderfully simple way of creating global stores, unfortunately in the SSR context we're going to need to recreate a defineStore
function.
It's going to look like this:
// useUser.ts
import { writable } from "svelte/store";
import { defineStore } from "./defineStore";
const useUser = defineStore(() => {
const user = writable<User|null>(null);
return {
user
}
});
// useProducts.ts
import { writable } from "svelte/store";
import { defineStore } from "./defineStore";
import { useUser } from "./useUser";
const useProducts = defineStore(() => {
const { user } = useUser();
const products = writable<Products|null>(null);
user.subscribe($user => {
if (!$user) {
products.set(null);
}
}
return {
products
};
});
We can see that the products store can even look up the user
store!
How does this work? defineStore
takes a function as a parameter. The first time the store is needed for the current session, the function is executed. The subsequent times, the cached result is returned:
import { useSession } from "./useSession";
export function defineStore<T>(fn: () => T): () => T {
return () => {
const { data } = useSession();
if (!data.stores!.has(fn)) {
data.stores!.set(fn, fn());
}
return data.stores!.get(fn) as T;
};
}
Easy peasy.
One particularity about SvelteKit is that it provides a fetch
function that should be used within load
. The reason is so that when the same calls are executed both serverside & clientside, the cached value can be used clientside instead of repeating the call.
Maybe you noticed, but in the above useLoad
, we stored input.fetch
in sessionData
.
To access it, we can create a useFetch
function:
export const useFetch = () => {
const { data } = useSession();
return { fetch: data.fetch };
}
And we can now improve our useProducts
function to add loadProducts
:
// useProducts.ts
import { browser } from "$app/env";
import { writable, get as $ } from "svelte/store";
import { defineStore } from "./defineStore";
import { useUser } from "./useUser";
const useProducts = defineStore(() => {
const { user } = useUser();
const { fetch } = useFetch();
const products = writable<Products|null>(null);
user.subscribe($user => {
if (!$user) {
products.set(null);
}
}
const loadProducts = async () => {
if (!$(products) && $(user)) {
const response = await fetch('/api/products');
products.set(await response.json());
}
}
return {
products,
loadProducts
};
});
Some of you maybe wondering why the useXxx
naming. Are we doing React? 😅
I find that this naming convention has been adopted for functions that need to be called in a particular context - the immediate context for the component.
It's used by another excellent Vue library, vueuse.
Here we decide it means a function that can be called either:
- In the immediate context of a component
- Through
useLoad
So, let's say we wanted to load the products inside a load
function. Here is what it would look like:
import { get as storeGet } from "svelte/store";
import { useLoad } from "$lib/use/useLoad";
import { useProduct } from "$lib/use/useProduct";
export async function load(input: LoadInput) {
const {products, loadProducts} = useLoad(input, useProducts);
await loadProducts();
return {
products: storeGet(products);
}
}
And here is what it would look like inside a component:
<script lang="ts">
import { useProduct } from "$lib/use/useProduct";
const {products, loadProducts} = useProducts();
const {user} = useUser();
$: loadProducts(), [$user] // reloads products each time user changes
</script>
{#each ($products || []) as product}
<Product {product}/>
{/each}
So that's it! We now have a uniform way of accessing per-user global stores, whether we're serverside or clientside, and whether we're in a load
function or inside a component.
As a bonus, we also have a uniform way of accessing the correct fetch
function ;)
52