SvelteKit is the newest thing on my list

August 30, 2026 code learningsveltekit

SvelteKit is the newest framework on my list, and building this blog's frontend with it is genuinely the first real thing I've shipped in it.

Before this it was mostly React: a fitness tracker app, a MERN stack store project (Mongo, Express, React, Node, the whole set). React's mental model, components, props, state, re-renders when state changes, took me a while to actually get comfortable with. SvelteKit asks you to think about almost none of that the same way, which was more disorienting than I expected going in.

the first bug was a habit, not a mistake exactly

My very first Svelte component was a little tag list for the admin post editor, add a tag, see it appear in the list. Straightforward, I thought, coming from React where I'd done this exact pattern a dozen times.

<script>
	let tags = ["python", "learning"];

	function addTag(newTag) {
		tags.push(newTag);
	}
</script>

<button on:click={() => addTag("sveltekit")}>add tag</button>

{#each tags as tag}
	<span>{tag}</span>
{/each}

Clicked the button. Nothing happened. Not an error, not a warning, the list just silently didn't grow, which is somehow more confusing than a crash would have been.

what I actually googled

"svelte array push not updating ui" turned up the exact issue almost immediately, and it's apparently common enough to have its own explanation in the Svelte docs that I clearly hadn't read yet: Svelte's reactivity is triggered by assignment, not by mutation. tags.push(newTag) mutates the existing array in place, and Svelte's compiler only knows to re-render when it sees an actual assignment to tags, something like tags = tags or a genuinely new array. Pushing changes the array's contents without ever assigning anything, so nothing tells Svelte to look again.

<script>
	let tags = ["python", "learning"];

	function addTag(newTag) {
		tags = [...tags, newTag];
	}
</script>

One line different. Spread the old array into a new one, assign it back to tags, and now every add is a real assignment Svelte can see. In React this exact habit is enforced by convention (setTags([...tags, newTag]) is just how useState works, you can't mutate your way around it even if you wanted to), so I'd never actually had to think about why the rule existed. Svelte let me mutate directly, which felt like less ceremony, right up until it didn't work and I had no idea why.

no virtual DOM to reason about

Svelte compiles your component into code that directly updates the DOM when a value changes. There's no virtual DOM diffing step to mentally model, which sounds like it should make things simpler and mostly does, except I kept looking for the useState equivalent that just isn't there in the same shape.

<script>
	let count = 0;
</script>

<button on:click={() => count += 1}>
	clicked {count} times
</button>

That's the whole component. Assigning to count is the reactivity trigger. In React I'd reach for useState out of habit and then remember: right, just reassign the variable, that's the whole mechanism here, and it's the same rule that broke my tag list, just working correctly this time because += is an assignment.

form actions were the actual surprise

Coming from React and Express, where every form submission means writing a fetch call on the client and a matching API route on the server by hand, SvelteKit's form actions confused me at first because they looked like they replaced the form instead of just handling its submission. My first instinct was to fight it, and build the React version anyway out of habit.

<!-- what I tried first, out of old habit -->
<script>
	async function handleSubmit(event) {
		event.preventDefault();
		const formData = new FormData(event.target);
		const res = await fetch("/api/comment", {
			method: "POST",
			body: JSON.stringify(Object.fromEntries(formData))
		});
	}
</script>

<form on:submit={handleSubmit}>
	<textarea name="body"></textarea>
	<button type="submit">Post comment</button>
</form>

Worked, technically. But it meant hand-writing an API route to receive that fetch, hand-writing the JSON parsing, hand-writing the error handling, all the stuff I'd already been doing in Express, none of which SvelteKit actually needed me to do myself.

// +page.server.ts, the SvelteKit way
export const actions = {
	default: async ({ request }) => {
		const data = await request.formData();
		const body = data.get("body");
		if (!body) {
			return fail(400, { error: "comment cannot be empty" });
		}
		// save it
		return { success: true };
	}
};
<form method="POST">
	<textarea name="body"></textarea>
	<button type="submit">Post comment</button>
</form>

npm run dev picking up a real component for the first time

I turned off JavaScript in devtools out of curiosity, on the plain method="POST" version. The form still worked. That's the moment it clicked properly: the action is the handler, and use:enhance is something you layer on top later to make it feel more like a single-page app, not something the form needs to function at all. My MERN store never worked like that. Kill the JS on that app and it was a static page with dead buttons, because the whole submission path lived entirely on the client.

the reactive statement that quietly used a stale value

$: statements are Svelte's other big reactivity mechanism, for values computed from other values, and I got bitten once by assuming they re-run more aggressively than they actually do.

<script>
	let posts = [];
	let searchTerm = "";
	$: filtered = posts.filter((p) => p.title.includes(searchTerm));

	async function loadPosts() {
		const res = await fetch("/api/posts");
		posts = await res.json();
	}
</script>

This looks fine, and mostly is. The bug showed up when I refactored loadPosts to mutate an existing array with posts.push(...newPosts) for a "load more" button instead of reassigning posts outright, the exact same mutation-versus-assignment mistake as the tag list, just wearing a different outfit this time. filtered never recalculated after "load more," because Svelte's dependency tracking for $: watches for assignment to posts, same rule as everywhere else, and a mutated array never triggers it. Once I recognized the shape of the bug from the tag list earlier, the fix was the same one line: reassign instead of mutate.

file-based routing tripped me up for about a day

Coming from Express, where every route is an explicit line of code (app.get("/posts/:slug", ...)), SvelteKit's file-based routing, where a folder named [slug] in the routes directory just becomes the dynamic route, felt like magic I didn't trust for the first day. I kept expecting to find the "real" router configuration somewhere and being confused that it didn't exist, that the folder structure genuinely was the whole routing table. Once it clicked it felt obvious, the same way the reassignment rule did, but the day of not trusting it was real.

what still trips me up

Reactivity through $: statements versus a plain reassignment isn't always obvious to me yet, exactly when Svelte re-runs something versus when it doesn't, especially once a dependency is buried inside a function call rather than referenced directly in the statement. I've gotten it wrong at least twice this month in ways that produced a stale value silently instead of an error, which is a worse failure mode than React's "forgot it in the dependency array" version. At least that one usually just re-runs too often instead of too rarely, which is the more forgiving direction to be wrong in.

svelte-check catching what I missed, before it caught nothing

where I actually land on it

Newest thing on the list, and also currently my favorite to actually write in day to day, mutation bugs aside. Whether that's the framework or just the honeymoon phase of anything new, ask me again in six months once I've hit whatever SvelteKit's equivalent of React's rules-of-hooks headache turns out to be. There's always one. So far mine has just been the same lesson twice, in a tag list and then in a reactive statement: change the value, don't just change what it points to in place.

2 comments

Log in to comment.

lene_dev September 1, 2026

Wait until the first time you fight load functions and figuring out where the data actually runs. Once it clicks it is hard to go back though. What made you pick it over Next?

admin September 2, 2026

Mostly that it got out of my way. Less boilerplate to keep in my head. Ask me again after I hit the first real headache.

Log in

No account?