Migrating HTML, CSS and JS site to Svelte 5 and SvelteKit
Runes used in this project:
$props()
$state()
$effect()
In my first blog post, I went over converting my static HTML and CSS site to use Svelte 5 and Svelte Kit. I recommend reading over that one first because it goes over a lot of basic setup that I won't spend time on in this one. In this post, I do something similar, but we also have JavaScript thrown into the mix.
The site I am converting this time is called "A Great Step", a (very inaccurate) tool for calculating the number of steps you should take each day to hit a given target weight in a given time frame. Seriously though, don't use it to set an actual walking goal, the maths are off even in the original project. 😅 The aim of migrating this app is to get a better understanding of Svelte 5 Runes, but also see how we can work with Local Storage. Here are the repos and the deployed links for the original project and the Svelte version:
Deployed site - Original A Great Step
Repo - Svelte version of A Great Step
Deployed site - Svelte version of A Great Step
Restructuring the app
The original site is just one long page that the user can scroll between sections. Since I wanted to get my Svelte practice in, I converted each slide to its own page and instead use page transitions. Unfortunately, they're not currently supported in Firefox or Safari, but they are a progressive enhancement, so adding them doesn't break anything in unsupported browsers.
In my original project's feedback, I was told that having the SVG code in the HTML is messy. I agree - I had big plans to animate them that never came to fruition. This time I created an svg
folder in the lib
folder and made all my SVG files into components to clean up the code.
I also moved the section content of each of the pages to its own component file inside the components
folder, and I made each slide into a page. After creating the pages and components, I still wanted a sliding effect, so I moved the styles to each component and added a page transition. I took that code from Rich Harris' demo on page transitions.
Animate the dialog element
This part isn't really about Svelte, but it's a nice thing to know. The dialog box on the homepage of the original site appears and disappears pretty abruptly. Luckily, it recently became possible to animate the display property of a dialog box, so I was able to add some code to make it transition more nicely.
Handling Local Storage
The way the original website worked was that each slide had a form you would submit, the form would be validated and then it would be added to Local Storage. In my Svelte app, I wanted to use Runes to update the value in Local Storage on user input. The Joy of Code Youtube Channel has a great video on adding a "spell", which is a a reusable utility. In that video, he creates a localStorage spell that was near perfect for my needs. I added the spell to the index.svelte.js file in the lib
folder. If you want to use Runes inside of a JS file like this, the extension must be .svelte.js
.
Passing state to components
Great, so now I had my +layout.svelte
, my +page.svelte
files and my Local Storage spell all ready. Now I just needed to set the state in my +layout.svelte
file and pass it down to the children. I set my userData
variable using let userData = new LocalStorage(<value>)
and decided to call my spell in the components, only to find I was getting this error:
Uncaught Svelte error: effect_orphan $effect can only be used inside an effect (e.g. during component initialisation)
Of course, the spell uses the $effect
Rune and I was trying to call it as if it were just another function. Basically, you can't do that and you should just be setting it at initialisation. So then I thought that I would set it in the +layout.svelte
and pass it down to the pages via props..... but wait... I can't. After some time looking, I turned to the Svelte Discord (I recommend joining if you haven't already) where I was informed that it is not possible. "You can not pass props from +layout
to +page
because Sveltekit renders the <PageComponent />
in the default slot (or children
snippet when using runes) for you." - that confirmation was all I needed, but they also provided the suggestion of using the Context API, which was the correct solution and one I wish I had gone with earlier.
Svelte Context API
After some trial and error (and probably still some error), I came across a video from the Huntabyte YouTube channel on global state that talks about using the Context API, with the knowledge from that video and the Joy of Code spell, I managed to create a function that sets context using the spell, which allowed me to set the userData
context in the +layout.svelte
so that it could then be accessed by its children. Here's how that looked:
+layout.svelte
let userData = setUserContext({ })
components/Goal.svelte
import { getUserContext } from '$lib/index.svelte'; let userData = getUserContext();
That seemed to work, but then I noticed that if I reloaded the page, I'd get a 500 error and it would say that my values were undefined. It turned out that I should have added all the keys and initial values when setting the context in the +layout.svelte
like this:
let userData = setUserContext({ firstName: '', lastName: '', email: '', gender: 'male', age: '', height: '', weight: '', daysAvailable: [], walkingFrequency: '', targetWeight: '', targetDate: '' });
Once I updated that, it worked like a charm. I just had to bind the userData
values to the inputs. For example, the firstName
input looked was
<input id="first-name" name="first-name" type="text" bind:value={userData.firstName} required />
or the gender radio buttons were bound using bind:group
like this:
<label> <input type="radio" value="male" name="gender" bind:group={userData.gender} checked={userData.gender === 'male'} /> Male </label> <label> <input type="radio" value="female" name="gender" bind:group={userData.gender} checked={userData.gender === 'female'} /> Female </label>
Importing existing userData
In the original project, the dialog on the homepage pops up, and you can add an existing JSON object that will populate the data. The way it worked is that you would add it in, submit and it would completely replace the localStorage object that was there. I found out that I couldn't just copy and paste this over to Svelte. Instead, I had to loop over the keys and update the values. Here's how that looks:
let textAreaObj = JSON.parse(textareaValue); let valueKeys = Object.keys(textAreaObj); for (const valueKey of valueKeys) { userData[valueKey] = textAreaObj[valueKey]; }
Script refactoring and hydration error
I was able to cut out a lot of code in my original script.js
file because I no longer had to select and update the elements using plain JavaScript. Instead, I could just output the localStorage value directly. When binding my values or outputting them to the DOM, I also received a number of variations of this error:
<section>(src/routes/walking-goal/+page.svelte:120:0) cannot contain <p> (src/routes/walking-goal/+page.svelte:162:4) This can cause content to shift around as the browser repairs the HTML, and will likely result in a hydration_mismatch warning.
Sometimes it was mentioning a div
, other times a p
, but in the end, it seems the solution (for me, at least) was to restart the server.
Conclusion
Now that I have migrated the project to Svelte I can now update the A Great Step project page on Stackportfol.io with the new deploy and repo links, and with Svelte and SvelteKit added as technologies:
All in all, it was another good learning experience and I'm looking forward to my next one, where I'll also start using TypeScript with Svelte 5. Stay tuned!