Svelte 5: We're not quite there yet
I am a big fan of Svelte. That being said, I’m not the biggest fan of some recent changes that came with Svelte 5. Here’s an incredibly opinionated and brief summary.
I miss createEventDispatcher
Let’s have a look at this ‘Catcher’ component.
<script lang="ts">
import Pitcher from '$lib/components/Pitcher.svelte';
import { type Ball } from '$lib/event/ball';
const catchIt = (e: Ball) => {
console.log(`Caught it. ${e}`);
}
</script>
<div>
<Pitcher header="Interesting Title" oneshot={false} on:throwIt={catchIt}/>
</div>
My goal here is simple: To take whatever the Pitcher component throws at me and
do something with it. What happens next is entirely in my hands. So, how
does Pitcher.svelte
make it work? Here’s how it was done in Svelte 4.
<script lang="ts">
import { type Ball } from '$lib/event/ball';
import { createEventDispatcher } from 'svelte';
export let header: string = 'Uninteresting title';
let value: string = 'some value';
let oneshot: boolean;
let dispatch = createEventDispatcher();
const throwIt = (e: Event) => {
if (!oneshot) {
dispatch('throwIt', {
payload: value
} as Ball);
}
}
</script>
<div class="one-of-a-bazillion-tailwindcss-classes">
<header>{header}</header>
<input type="text" bind:value />
<button on:click={throwIt}>
Throw!
</button>
</div>
Imagine this component as the pitcher, and you are the catcher. The pitcher doesn’t care whether you catch the balls or let them drop. The separation of responsibilities here is clean and clear.
But here’s what the same example looks like in Svelte 5. For the parent component, you need to change one line:
<Pitcher header="Interesting Title" oneshot={false} onthrowit={catchIt}/>
Pitcher.svelte
requires more significant adjustments.
<script lang="ts">
import { type Ball } from '$lib/event/ball';
interface Props {
header?: string;
value?: string;
oneshot: boolean;
onthrowit: (e: Ball) => void;
}
let {
header = 'Uninteresting title',
value = 'some value',
oneshot,
onthrowit
}: Props = $props();
const throwIt = (e: Event) => {
if (!oneshot) {
onthrowit({saveData: value} as Ball);
}
}
</script>
<div class="one-of-a-bazillion-tailwindcss-classes">
<header>{header}</header>
<input type="text" bind:value />
<button onclick={throwIt}>
Save!
</button>
</div>
What happened?
- Instead of dispatching event, you pass a callback function now.
- The clear separation of concern disappeared.
- The clear semantic differentiation between a component property and an event disappeared.
Instead of allowing the pitcher to decide what ball to throw, you hand the ball to them beforehand. I don’t like touching others’ balls. I don’t mind what framework requires me to pass callbacks, I don’t like it and I never will.
To make matters worse, onclick
could still be written as on:click
. Both are
available. In my opinion, picking a consistent camel case naming convention would
be more suitable. What I observed is that this approach will likely lead to
developers using a mixture of camel case and lowercase function names for
custom events, introducing unnecessary inconsistency.
While this may seem minor, the colon (:
) in on:click
provided some clarity at first glance.
Removing it feels like losing a small but useful visual cue.
The experience is still great
On the upside, and to end with a positive undertone, there is not much left to nitpick.
Runes are a blessing and a huge step in the right
direction. Beyond the rad name, they finally fixes many of the syntactically strange tricks that were
necessary to enable reactive values. Custom Elements
make it possible to package my code for non-Svelte applications. While more verbose, the new
#snippet template syntax allows for more flexibility than
the previous <slot />
approach in custom components.
That’s why I’m so baffled by the decision on how component events are now handled. I hope that in the future, the positive changes continue to outweigh the negatives, and it’s been a long time since I’ve read a changelog and thought to myself, “Yep, that’s good”.