In this post, I’ll describe how to build rich, dynamic path-based navigation using SvelteKit. It builds on SvelteKit’s routing capabilities, as well as leveraging the Svelte stores it provides to represent path and navigation state. It isn’t a trivial example, I want to show how a real-world application might work with all the code (some HTML redacted). Authentication and fetching data is left out, as topics for other posts.
Working code for this example is available at svelte-kit-navigation, which can be cloned and run locally.
Updated on 2022-09-15 for compatibility with the latest version of sveltekit
Setup
We are running svelte ^3.40
, @sveltejs/kit ^1.0.0-next.483
, and a few extra libraries - @sveltejs/adapter-static
, tailwindcss
, postcss
and others. You can see the full package list at this link.
Summary
The main moving parts for this approach leverage features of SvelteKit — the provided load function, goto function, and $page and $navigating stores. It also uses SvelteKit’s support for dynamic paths, to encapsulate the state necessary to display the page’s intended contents. These features provide reactivity to changes in navigation and the component variables of the path, including query variables. SvelteKit also intercepts all clicks on links, allowing us to use standard HTML for navigation.
A big advantage of this approach is that it supports deep linking into your application, with rendering of each page consistent, even temporary states with modals or notifications. It also simplifies complex navigation in a SPA, without any special handling for the back button or copied links, since the page URLs are driving the details of data loading and rendering.
Detailed Overview
This example has an index page at the root path, and a page of “transactions”. Paths take a pattern of /resource/resource_id?queryParam=queryValue
, and can be extended to include subpages. So a page displaying a list of transactions would match /transactions
while displaying the details of a single transaction could match /transactions/000-111-000
where “000-111-000” is the transaction id. SvelteKit calls these “dynamic paths” and will extract the dynamic parts of the path as variables.
The site uses a standard SvelteKit src/routes/+layout.svelte
for each page, which serves as the parent component of subsequent pages. This is a good place to initialize “global” stores with state that child components might need. There are a few states that we manage at the top level, a “loading” state while the app goes through an initial setup (such as initial user state), and authentication state to conditionally render a login prompt.
Dynamic routes
From SvelteKit’s documentation:
At the heart of SvelteKit is a filesystem-based router. This means that the structure of your application is defined by the structure of your codebase — specifically, the contents of src/routes
This includes “dynamic” pages that get encoded using [brackets]
as the parent directory of the +page.svelte
file name. For example, the file src/routes/transactions/[...id]/+page.svelte
will match paths myapp.com/transactions
as well as myapp.com/transactions/00-11-00
, with the latter containing an id parameter that gets parsed and passed as a prop.
Load function
This function, provided by SvelteKit, runs before each page “load”, and parses the id from the path if available, passed into the component as a prop. It’s important to note that the load function must be declared in a module script, and the variable for the prop must be exported.
In our testing, child components cannot declare additional load functions, but we’ll detail an approach that works for those below.
The load function will run each time navigation occurs, including links and the back button. You can see a full example at /transactions/[…id]
<script>
import { goto } from '$app/navigation';
import { writable } from 'svelte/store';
import ...
// This variable is set from the load function in +page.js
export let data
// We use stores to reference the list of transactions as well as the transaction details
// for the currently selected transaction.
const transactions = writable(data.transactions);
const selectedTxn = writable(undefined);
// Call method reactively when transaction id changes
$: setupPage(data.transaction_id, $transactions);
//... continued below
</script>
Setup page function
In our component’s <script>
section, we define a function called setupPage()
. This function is responsible for setting component variables consistent with the current path. It will be reactive to changes in the path variables, invoked through reactive blocks and store subscriptions. This function should be consistent when setting state as it can be called multiple times in certain scenarios due to multiple subscriptions. As a result it’s best for this function to also be synchronous and not fetch external data (which is better done on mounting).
<script>
// ... continuing from above
// Main function for setting the correct state on the page.
// This idempotent function sets the selected transaction data
// based on the transaction id from dynamic path.
// It identifies the selected transaction from the list of all transactions loaded
// when the component mounts.
function setupPage(txn_id, txns) {
// If no transaction id is set in the path, default to the first transaction
// This handles the path "/transactions"
if (txn_id === '' && txns.length > 0) {
goto(`/transactions/${txns[0].id}`)
return
}
if ($selectedTxn?.id != txn_id) {
const txn = txns.find((f) => f.id == txn_id)
if (!txn) return
$selectedTxn = txn
}
}
// Also run the setupPage function when the list of transactions changes
transactions.subscribe((ts) => setupPage(transaction_id, ts))
</script>
URL query parameters
We use URL query parameters to display intermediary states, such as forms or modals, that toggle on or off. In the example app, there are links to open a “create transaction” form, and a button to dismiss the form.
To show the form, we use a shorthand link to add the parameter to the current path.
<a href="?new=t">
<!-- link contents -->
</a>
Dismissing the form takes a bit more code, as we want to only remove the parameter new
without modifying the rest of the path. We can use the SvelteKit goto
method to navigate without resetting the position or focus of the current page.
<button
on:click={() => {
// Hide form by unsetting query param new
$page.url.searchParams.delete('new');
goto(`${$page.url.pathname}?${$page.url.searchParams.toString()}`, {
noscroll: true,
keepfocus: true
})
}}
>
Cancel
</button>
Child components and $navigating store
Since the load
function is scoped to the entire component, in the case when child components need to be reactive to navigation we use subscriptions on the $page
and $navigating
stores. These are also used to invoke the setupPage()
method.
In the example below, we have a child component displaying the details of a transaction. It also displays a form for creating a new transaction, based on a query parameter value in the URL path. The $navigating
store has a few states that transition during navigation, please refer to the SvelteKit docs for full details. Here we react to the state where a to
object represents the next page being loaded.
<script>
import { page, navigating } from '$app/stores';
let showForm = false;
const unsubs = [];
// Show form based on url parameters
// Svelte-kit page store contains an instance of URLSearchParams
// https://kit.svelte.dev/docs#loading-input-page
function setupPage(url) {
if (url.searchParams.get('new') == 't') {
showForm = true;
} else {
showForm = false;
}
}
// Subscribe to page and navigating stores to setup page when navigation changes
// Note that, in our testing, the Svelte-kit load function does not fire on child modules
// This is an alternative way to detect navigation changes without a component load function
unsubs[unsubs.length] = page.subscribe(setupPage);
unsubs[unsubs.length] = navigating.subscribe((n) => {
if (n?.to) {
setupPage(n.to);
}
});
// ... full component below
Put it all together
Here is the entire component. Transaction data is fetched during onMount and added to stores, and current transaction details are displayed based on the navigation. “Selecting” a transaction to view details is done through regular <a href>
links or programatically using the goto
method provided by SvelteKit.
Changes to navigation or state invoke the setupPage(...)
method which ensures component variables are set correctly.
Also note the use of a URL query parameter ?new=t
which opens (and closes) a form for “creating” a new transaction.
src/routes/transactions/[…id]/+page.js
import { fetchTransactions } from '$lib/api'
// Pass the id parameter from the dynamic path slug corresponding to /transactions/[id]
// This gets set to the data.transaction_id variable
export async function load({ params: { id } }) {
const transactions = await fetchTransactions()
return { transaction_id: id } };
}
src/routes/transactions/[…id]/+page.svelte
<script>
import { goto } from '$app/navigation';
import { writable } from 'svelte/store';
import { onDestroy, onMount } from 'svelte';
import TransactionDetails from '$lib/Transaction/details.svelte';
// This variable is set from the load function above
export let data;
// We use stores to reference the list of transactions as well as the transaction details
// for the currently selected transaction.
const transactions = writable(data.transactions);
const selectedTxn = writable(undefined);
// Track subscriptions to wrtable stores, to unsubscribe when the component is destroyed
const unsubs = [];
// Main function for setting the correct state on the page.
// This idempotent function sets the selected transaction data
// based on the transaction id from dynamic path.
// It identifies the selected transaction from the list of all transactions loaded
// when the component mounts.
function setupPage(txn_id, txns) {
if (txn_id === '' && txns.length > 0) {
goto(`/transactions/${txns[0].id}`);
return;
}
if ($selectedTxn?.id != txn_id) {
const txn = txns.find((f) => f.id == txn_id);
if (!txn) return;
$selectedTxn = txn;
}
}
// Call the setupPage method reactively when the transaction_id is changed
$: setupPage(data.transaction_id, $transactions);
// Call the setupPage method reactively when the list of all transactions is changed
unsubs[unsubs.length] = transactions.subscribe((ts) => setupPage(data.transaction_id, ts));
// Fetch all transactions when this component mounts
onMount(() => {
fetchTransactions().then((ts) => {
transactions.set(ts);
});
});
// Unsubscribe from all subscriptions
onDestroy(() => unsubs.forEach((_) => _()));
</script>
<div class="flex flex-row">
<div class="w-1/4">
<div class="flex flex-row m-2 mt-6 justify-between">
Transactions
<a href="?new=t">
<!-- SVG details omitted for conciseness -->
<svg />
</a>
</div>
<ul class="flex flex-col">
{#each $transactions as txn (txn.id)}
<li
class:active={txn.id == data.transaction_id}
class="m-2 border border-emerald-900 rounded-sm p-2"
>
<a href={`/transactions/${txn.id}`} class="linklike">Transaction {txn.id}</a>
</li>
{:else}
<li>No transactions</li>
{/each}
</ul>
</div>
<div class="w-3/4">
{#if !$selectedTxn && $transactions?.length == 0}
<!-- empty page element goes here -->
{:else if $selectedTxn}
<TransactionDetails ransaction_id={data.transaction_id} />
{:else if data.transaction_id}
<div>Transaction {data.transaction_id} not found</div>
{/if}
</div>
</div>
<style>
li.active {
--tw-bg-opacity: 1;
background-color: rgb(209 213 219 / var(--tw-bg-opacity));
font-weight: 700
}
</style>
src/lib/Transaction/details.svelte
<script>
import { page, navigating } from '$app/stores';
import { goto } from '$app/navigation';
import { writable } from 'svelte/store';
import { onDestroy } from 'svelte';
export let transaction_id;
let transaction = writable(undefined);
let showForm = false;
const unsubs = [];
// Show form based on URL parameters
// Svelte-kit page store contains an instance of URLSearchParams
// https://kit.svelte.dev/docs#loading-input-page
function setupPage({ url }) {
if (url.searchParams.get('new') == 't') {
showForm = true;
} else {
showForm = false;
}
}
// Subscribe to page and navigating stores to setup page when navigation changes
// Note that, in our testing, the Svelte-kit load function does not fire on child modules
// This is an alternative way to detect navigation changes without the component load function
unsubs[unsubs.length] = page.subscribe(setupPage);
unsubs[unsubs.length] = navigating.subscribe((n) => {
if (n?.to) {
setupPage(n.to);
}
});
async function fetchTransactionDetails(txn_id) {
if (!txn_id) return;
// In normal circumstances, a call to an API would take place here
// const api = fetchapi(`/api/transactions/${txn_id}`)
// const res = await api.ready
const res = await Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
data: {
id: txn_id,
name: `Transaction ${txn_id}`,
user: 'Not a person',
amount: '1 million dollars'
}
})
});
if (!res.ok) throw new Error('Network error');
const json = await res.json();
transaction.set(json.data);
}
$: fetchTransactionDetails(transaction_id);
onDestroy(() => unsubs.forEach((_) => _()));
</script>
{#if !showForm && $transaction}
<div class="m-6 p-6 border border-gray-600 rounded">
Details for {$transaction.name}
<div class="grid grid-cols-2 pt-6">
<div>Id: {$transaction.id}</div>
<div>Name: {$transaction.name}</div>
<div>User: {$transaction.user}</div>
<div>Amount: {$transaction.amount}</div>
</div>
</div>
{/if}
{#if showForm}
<div class="m-6 p-6 border border-gray-600 rounded">
Create new transaction
<form class="grid grid-cols-2">
<label for="name">Name</label>
<input type="text" name="name" value="" />
<label for="user">User</label>
<input type="text" name="user" value="" />
<label for="amount">Amount</label>
<input type="text" name="amount" value="" />
<button
name="cancel"
class="border border-violet-800 bg-violet-100 rounded-md w-16 mt-2"
on:click|preventDefault={() => {
// Hide form by unsetting query param new
$page.url.searchParams.delete('new');
goto(`${$page.url.pathname}?${$page.url.searchParams.toString()}`, {
noscroll: true,
keepfocus: true
});
}}
>
Cancel
</button>
<button name="save" class="border border-violet-800 bg-violet-100 rounded-md w-12 mt-2"
>Save</button
>
</form>
</div>
{/if}
Here is a screenshot of the example app in action. Note the transaction id in the path, and the corresponding details selected on the page being displayed!
Svelte stores are one of the more powerful features of the Svelte framework. Importing and subscribing to state allows for component encapsulation without passing all data as props through children.
Conclusion
I’ve been working with SvelteKit for a few months now and am really enjoying the experience. There have been rare moments of coding delight as something just works in Svelte as intuited. This is in contrast with my experience in React or NextJS, where I found components, lifecycles and hooks downright befuddling at times. Svelte solves just enough problems that make reactive web page development easy, and doesn’t hide much behind magic.
Using path-based variables and parameters to set component state ties together the ease of state management in Svelte along with people’s normal browsing behavior of saving links and using the back button. Additionally, driving state changes through the path drives a consistent approach to component data that simplifies the execution flow of code across a Svelte app.
We will continue to post about our use of Svelte and experience in the broader Svelte ecosystem of tools and extensions. If you found this article helpful, we would love to hear from you!
Happy coding adventures! - The JumpWire team