SvelteSnaps

Rich Harris builds an Instagram clone, showcasing the different features of SvelteKit.

Transcript

Hi, friends.
It's me, Rich.
If you went on the Svelte Summit website this week
and looked at the speakers,
you would have seen my face in a box that said TBD secret,
which makes this talk sound way more exciting
than it actually is.
It sounds like there's gonna be a big reveal or something,
and sadly, there isn't.
I would say you should blame Kevin
for over-egging the pudding,
but the reality is it's all my fault.
I was just too disorganized
to send him some actual information.
So no big reveal.
I'm just gonna yammer on for 20 minutes or so
about an app that we've been working on for the last week.
The context for this is that I work at Vassell,
and last week was Vassell Ship Week,
in which we launched a whole bunch of new products.
I'm not gonna recap them,
because you can just watch the keynote online.
If you do, you might notice a familiar face
in the background.
They let me be an extra for the day.
I got makeup done and everything.
Vassell production values are kinda nuts.
Anyway, among other things,
we launched integrated storage products.
With a couple of clicks, you can add a Postgres database
and blob storage to your products, which is really neat.
One of the things that I do at VSL
is I try out new products that are in development
to make sure that they work really well with SvelteKit.
So I thought it would be fun
to try and build a photo sharing app,
which would use Postgres and Blob Storage.
It would also be an opportunity to work on
one SvelteKit feature in particular
that I've wanted for ages,
which has been in our issue tracker since October, 2021.
That feature is something called shallow routing,
and it gives you a really nice way to tie state to history
without causing navigation to occur.
Finally, I wanted to try and build something with Tailwind.
I've always thought Tailwind had some really persuasive
arguments about the right way to do CSS,
but I've always looked at Tailwind code
with absolute revulsion.
And I wanted to challenge myself to get over the hump
and see if it started to make any sense.
And my verdict is, I actually really like it.
There are some things I don't love.
It gets very repetitive and the solutions
for avoiding that repetitiveness,
kind of undo a lot of the niceness.
And there are some fundamental limitations
with the tailwind approach that means it cannot solve
all your CSS problems and probably never will.
And we'll talk about those later.
But on the whole, I was pleasantly surprised.
So here's the app.
It's called Svelte Snaps,
and you can access it yourself
by going to sveltesnaps.vercel.app.
I'm gonna give you a tour of this app
and show you how each bit is implemented.
The first thing we obviously need to do is log in.
And if we use the inspector,
then we can find the component that lets us do that.
It's this login.svelte component,
which is really just a very simple form.
When you click on it,
it posts to the login action of the auth page.
So let's open that.
And you can see that it has these login
and logout actions there.
Are we using Discord to do our authentication?
When you post to this action, it will construct a URL with this is the base and with all of
these bits.
Note that we have some state here, which is just a random UUID.
That is attached to the browser as a cookie, and it's also attached to the URL that we
use for authentication as a query parameter.
And then we just redirect to that URL.
And so when we do that, it's going to send me to the Discord login page.
And it's logged me in already.
The way that it did that was by redirecting me back to the redirect URI that I specified,
which is this auth callback route.
So if we visit that, you'll see that what it's doing is it's first checking that the
the state in the cookie and the state in the,
that it was passed match each other.
And then we swapped the code that we got back
for an access token, then we swapped the access token
for a user using these functions down here.
And then once we've got that user information,
we put it into our Vizel Postgres database
using just raw SQL.
I know some of you are looking at this
and being like, "Rich, why are you not using
"a type safe query builder?"
Can I be real for a minute?
They all suck.
Even the really good ones suck.
Every time I've tried to use something like Prisma
or whatever, I've come back to raw SQL like,
"Please forgive me, I missed you."
Just write SQL and use type coercion.
It's totally fine.
So, we add this user data to our database
And then we create a session, which is going to give us a unique ID that we then attach
to our cookies, like so.
And then we just redirect back to the home page.
So you could use an auth library for this kind of stuff if you wanted, but I think it's
pretty cool that we can just do this very easily without libraries.
After logging in, I can see a bunch of photos, but you can't because you haven't posted any
and you're not following anyone.
I haven't added a what's hot page or a search function or anything like that because then
I would need to think about moderation and this would no longer be fun.
So it will only show you images and photos from people that you've followed.
So let's upload something.
Down here in the right we have an upload button and if we inspect the component we can see
how it works.
So this is inside a form which posts to the post action of the root root.
And the input that we click on has an on change handler, which gets the current file and then
calls this push state function.
Push state is a new function that exists in a branch of SvelteKit that I have running
locally.
And what it means is create a new history entry with this state, this show uploader
true associated with it.
And so if we select a file, in fact, let's first take a screenshot so that we have something
to select.
I'm just going to screenshot this code.
And then, then I'll upload that file that we just created.
And you'll see this modal pops up.
That's because up here, we're showing the modal when page.state.showUploader is true.
If I'm on mobile and a modal pops up that I want to dismiss, I'll often
do it with like the swipe back gesture which has the effect of going back
through the history stack but often the modal isn't actually associated with a
history entry so what happens instead is I get kicked out to the previous history
entry which you know often means I get kicked out of my in-app browser back to
Twitter or whatever it's a really sucky experience so it's important that this
modal here is associated with history and here it is. Before this new feature
feature, this was super hard to get right with SvelteKit. Maybe even impossible. But
now it's extremely easy. So here's the pull request for this feature. We're hoping to
land this very soon. So inside this modal, I can add a description and hit enter, and
it will create my new photo. But this wouldn't be a SvelteKit demo app if we didn't take
progressive enhancement really seriously. So if you don't have JavaScript for
whatever reason then you're not going to get this modal. So what can we do? Well
let's disable JavaScript here. Let's go down to there, disable JavaScript, reload
the page. So the solution I came up with is this. We make the input type equals
file required and then we can use CSS to show this upload button down here when
the input is valid. Let me show you how that looks. So we'll click the button, add
an image, and this upload button appears. Now in order to do that we need to use a
pretty funky CSS selector. I could be wrong but I don't think this is
achievable in Tailwind because Tailwind is all about styling elements in
isolation and a lot of the most interesting CSS challenges involve
thinking about the relationships between elements.
This is the same mistake that a lot of CSS and JS solutions
make in my experience.
So Tailwind gets you 90% of the way there,
but it's not enough.
You still need to be able to associate real CSS
with your components, and Svelte Scopes
dials are obviously where it's at.
So when we click that button, we run our post action.
Run our post action.
Let's bring that up.
It's going to read our file data and then use the Vercel
blob API to store it, giving us back a permanent URL.
And we're getting the width and height by reading the raw image data using a fork of
a package called image size.
And we're storing that in the database so that we can avoid layout shift when we render
the photo.
Now because the description of a photo is required when you submit the photo through
JavaScript, we know inside this action that if there is no description, like this, that
we can't publish it yet, that we came from a Node.js submission, and so we need to go to this
publish route, which you can see on the right here. And so now I can add a description,
hit enter, and we're now on the photo page. Now, hopefully it goes without saying that
this works without JavaScript. I can toggle the light button, I can add a comment,
I can delete the comment and all of this just works without any real effort on
the part of the developer because I'm just using SvelteKit form actions. Of
course most of the time I do have JavaScript enabled which means that we
can do optimistic UI so I'm gonna re-enable JavaScript and reload the page.
So now when I toggle this button we get instantaneous feedback. Let's look at
how that's implemented. Again we'll just open the component using the inspector
so that we can find the form. So what we're doing here is we're grabbing a
list of the accounts that have liked the photo as well as the current user
account from our data object and if I click on go to definition then I can see
where this is being defined it's in the page server.js next to next to the
component and we can see the SQL that generated this query. So when I toggle
this, I don't really need to go back to the server. I can just update it in place. And if the action
doesn't succeed, then I just revert the data. Now, this code might look really suspicious to you.
We're just mutating the data object. Can you do that? Isn't it super sketchy to just mutate stuff?
Maybe. I don't know. I, Rich Harris, inventor of Svelte, hereby give you permission to mutate
your data. The only condition is that you should mutate it to look like what it would have looked
like had it come from the server. You can get much fancier with optimistic UI, but you can get a long
way by just toggling stuff. Now if we look at the deployed version of this image, we'll see that it's
coming from this _Vercel/image endpoint. And that's happening because the image
is using this optimize function. And optimize is just replacing the URL with
slash underscore Vercel slash image. This is a Vercel feature called image optimization,
which lets us serve smaller versions of images based on the user's browser and screen size.
So we upload full-size PNGs and JPEGs, but we get back compressed WebP and AVIF files.
To enable image optimization, I've currently got this script which runs after each build,
but I think we're probably going to just add this to the Vercel adapter in the near future.
Let's go to my profile page. It's got my Discord username and avatar because we grabbed those when
I logged in. Apparently Discord is changing how usernames work soon, so I don't know if
this will work in future, but like it works today, so whatever. This page gets its data
from an API root, which executes this monster SQL query.
I did not write this, chat-gpt wrote this.
A nice thing about just using SQL is that chat-gpt
is really good at writing SQL.
You might wonder why we're using an API root
instead of just running that query inside our load function.
Well, that will make sense in a minute.
Remember, in SvelteKit, when you see a fetch like this,
which is using the fetch that was passed
into the load function,
We're not actually making a network call.
It's calling your API route directly like a function.
So there's no overhead.
Okay, so the first thing you'll notice on this page
is that all the photos are a bit wonky.
And this is deliberate.
I wanted this app to feel a little bit rough and homemade.
So the photos are kind of irregular
as if they've been laid out on a surface.
And we use a handwritten font and a bunch of icons
that I'm proud to say I drew myself.
You might think it looks lame and that's fine,
but I would rather look lame than look the same
as everything else.
I find a lot of modern web design utterly charmless.
Everything is so sterile and homogenous.
Everyone's using the same design systems and color palettes.
So even though I'm not much of a designer,
I really enjoy the feeling of carving something unique.
And I was pleasantly surprised to discover
that Tailwind didn't get in my way,
which I thought it probably would.
Okay, so how do we make the wonkiness work?
Let's open up our photo list component and take a look.
Now we can't just use math.random
because true randomness rarely looks good
and because we want consistency
between server and client renders.
So if we look here, we're actually just alternating
between even and odd.
And the fact that the photos are different aspect ratios
means that we get a decent amount of visual variety.
So it kind of works.
This page implements infinite scroll,
which means two things.
Firstly, we're only showing a subset of photos
at any one moment.
And here in DevTools, we can see these data item ID elements.
There are only two in the DOM right now.
And as we scroll, photos will get added and removed,
depending on what should currently be visible.
In order to demonstrate the effect,
we're literally only showing photos as they become visible.
Normally you would have a bit of a buffer on either side.
This is all happening in this scroller component,
which has this handle scroll function,
which is updating the window every time we scroll
and adding padding to the top and bottom of the element
to compensate for the items that are being removed.
You'll notice that the images fade in as they load,
and that's happening because every image
has this smooth load action,
which sets the opacity to one when the image is loaded.
But you'll notice that because we've stored the width
and the height of each image in the database,
we don't get layout shift as these images load.
So we've talked about windowing,
but the other thing our scroll needs to do
is tell us when to load more data.
It does that by dispatching a more event
when it detects that we're getting close
to the bottom of the scroller.
And the parent component, our photo list component,
is listening for that more event,
and when it hears it, it loads data from the same API route
that we use to populate the initial data on the page,
but we're adding this start query parameter,
which tells it to skip the first however many photos.
In turn, the photo list component emits its own event
to tell the page that some data has been loaded.
And here's that event handler.
Now again, we're mutating the data object,
which might feel a little bit naughty,
but I find it to be a pretty effective technique.
And of course, hopefully it goes without saying
all of this works without JavaScript. If we just disable it and reload the page,
then instead of getting infinite scroll we get pagination. I've turned JavaScript
back on so that we can talk about another use of the new push state
feature. You might have seen this pattern on other websites like Instagram. If you
interact with something in a long list it's kind of nice if you can open it on
the same page like this instead of losing your context. So how does this
work? Well on each a element we have an on-click handler here and the first
thing we do is we bail out if the meta key is pressed because that means that
the photo is being opened in a new tab. We also bail out if the window
is small enough because we only really want this to happen on larger screens. If
neither of those conditions is met then we prevent the event default because we
don't want SvelteKit to route to this page. After that we need to load the data that we're about to
display. Now we could have written this app in such a way that we didn't need any additional
data. We could just use the list item instead to populate the contents of this modal. But as it
happens the photo page has a list of comments, whereas the list page just has numbers of
comments. So we do need to load some additional data. Now we could use another API route and then
fetch the data and that would be fine but we'd be losing out on one of SvelteKit's neat features
which is preloading. Let me pull up the network tab so that I can show you what I mean. When you
hover over a link SvelteKit's default behavior is to load the code and the data for that route
in anticipation of you clicking on the link. We can see that in the network tab here if I hover
over this photo it will load some data from the server so that it can navigate to that page like
like that, that underscore underscore data dot JSON file.
Now it would be pretty cool if we could use
that same mechanism even though we're not using
SvelteKit's router, and that's what
this preload data call does.
Preload data already exists in SvelteKit.
Here are the docs.
But today all it does is sort of prime the pump
so that when you do navigate, the data is all ready.
But here, it's actually returning the data for the page.
And because we're already hovering on the link,
we can access data that the router was preloading anyway,
which means that when we do click it,
the modal pops up instantly
without any more network activity.
On mobile, there's obviously no such thing as hovering,
but we can start preloading on touchdown
instead of waiting for a click.
So you still get a nice speed boost.
So if everything goes well,
We just call pushState with the resulting data
and we pop up this modal.
Similarly to before, we're just showing the modal
inside an if block where page.state.selected
is the page data that we previously loaded.
This photo page component is literally just
the page.svelte component that we would have rendered
if we had navigated to this page in a new tab.
So we're being efficient about reusing code.
If we wanted to, we could load the photo page component
lazily, but for simplicity's sake,
I'm not bothering with that here.
Now you'll notice that my push state call up here
has a second argument this time, the original link href.
And as a result, the URL bar has updated to the photo page,
even though I didn't navigate there.
That means that I can share this URL,
and if I reload the page, it'll take me to the right place.
So I'm pretty excited about this feature.
it is super easy to use, but I also have a ton of flexibility as to how I use it.
Right now I'm on Conduitry's page.
And if I scroll down far enough that it loads some more data from the API
route and then click to my feed page, it will load some new data, but watch
what happens now when I go back.
Not only does it maintain my previous scroll position, but it's also using
the data that was previously in memory.
not the initial data that comes from the server.
Now, how can that be?
The answer is that we're using a relatively new feature
called snapshots.
When we navigate away from this page,
we run this capture function.
And then if we return back to it, like so,
we run the restore function
with the previously captured values.
So we're actually just replacing the initial data
with this stuff that we had lying around in memory
and resetting the scroller to its previous scroll position.
This is the sort of thing that you would normally need
a very elaborate client-side state management system
to pull off, and the fact that we can do it
with a pretty small amount of code
honestly feels like cheating.
Now, there are some limitations.
For example, we're still loading the initial data
from the server, even though we immediately discard it.
It would be nice if we had a way to prevent that.
There is an open issue for that here,
which you can comment on if this is a feature
that would be interesting to you.
Another problem is that data can get stale
if you're not careful.
For example, if I pop up this modal here and add a comment,
then by the time I close the modal,
I want the comment count to be updated.
At this point, you might think that we need
a really convoluted state management system,
but we really don't.
We just need this very simple store.
This is all the code that's required to keep things in sync.
Let me just update it with data from the server
whenever we get it.
This is all pretty hacky,
and I'm sure you'll find creative ways to break it,
but it's a lot of fun to explore the limits of this stuff.
At the moment, it feels like every framework
is scared of running JavaScript in the browser
and is retreating to the server,
but I think it's a mistake.
If we can update our UIs
without always deferring to the server
and without over-engineered state management,
then we should.
All right, that's my time.
I had an absolute blast building this app.
A sad truth about being a framework maintainer
is that you're often looking at problems
in a slightly abstract or isolated way.
So it's really helpful to sharpen your knives
on real world problems from time to time.
And I hope we get more chances to do this sort of thing.
I hope this was useful to you
and you picked up some new ideas or spelt git tricks.
If you have thoughts on the new APIs
and how you'd like to see them evolve,
then GitHub is waiting for you.
Thanks for watching.

Svelte Summit is a conference made possible by Svelte School AB and Svelte Society.