Fine-Grained Reactivity in Svelte 5
Fine-Grained Reactivity in Svelte 5 êŽë š
Weâve been looking at the up and coming Svelte 5. We looked at basic features like state, props, and side effects. Then we looked at Snippets, which is a lightweight feature Svelte added for re-using bits of HTML within (for now) a single component.
Article Series
In this post, weâll take a close look at Svelteâs new fine-grained reactivity.
What is fine-grained reactivity?
The best way to describe fine-grained reactivity is to show what it isnât, and the best example of non-fine grained reactivity is React. In React, in any component, setting a single piece of state will cause the entire component, and all of the descendent components to re-render (unless theyâre created with React.memo
). Even if the state youâre setting is rendered in a single, simple <span>
tag in the component, and not used anywhere else at all, the entire world from that component on down will be re-rendered.
This may seem absurdly wasteful, but in reality this is a consequence of the design features that made React popular when it was new: the data, values, callbacks, etc., that we pass through our component trees are all plain JavaScript. We pass plain, vanilla JavaScript objects, arrays and functions around our components and everything just works. At the time, this made an incredibly compelling case for React compared to alternatives like Angular 1 and Knockout. But since then, alternatives like Svelte have closed the gap. Myfirst poston Svelte 5 showed just how simple, flexible, and most importantly reliable Svleteâs new state management primitives are. This post will show you the performance wins these primitives buy us.
Premature optimization is still bad
This post will walk through some Svelte templates using trickery to snoop on just how much of a component is being re-rendered when we change state. This isnotsomething you will usually do or care about. As always, write clear, understandable code, then optimize when needed (not before). Svelte 4 is considerably less efficient than Svelte 5, but still much more performant than what React does out of the box. And React is more than fast enough for the overwhelming majority of use cases âso itâs all relative.
Being fast enough doesnât mean we canât still look at how much of a better performance baseline Svelte now starts you off at. With a fast-growing ecosystem, and now an incredibly compelling performance story, hopefully this post will encourage you to at least look at Svelte for your next project.
If youâd like to try out the code weâll be looking at in this post, itâs all inthis repo.
Getting started
The code weâll be looking at is from a SvelteKit scaffolded project. If youâve never used SvelteKitbefore thatâs totally fine. Weâre not really using any SvelteKit features until the very end of this post, and even then itâs just re-hashing what weâll have already covered.
Throughout this post, weâre going to be inspecting if and when individual bindings in a component are re-evaluated when we change state. Thereâs various ways to do this, but the simplest, and frankly dumbest, is to force some global, non-reactive, always-changing state into these bindings. What do I mean by that? In the root page that hosts our site, Iâm adding this:
<script>
var __i = 0;
var getCounter = () => __i++;
</script>
This adds a globalgetCounter
function, as well as the__i
variable.getCounter
will always return the next value, and if we stick a call to it in our bindings, weâll be able to snoop on when those bindings are being re-executed by Svelte. If youâre using TypeScript, you can avoid errors when calling this like so:
declare global {
interface Window {
getCounter(): number;
}
}
export {};
This post will look at different pages binding to the same data, declared mostly like this (weâll note differences as we go).
let tasks = [
{ id: 1, title: "Task A", assigned: "Adam", importance: "Low" },
{ id: 2, title: "Task B", assigned: "Adam", importance: "Medium" },
{ id: 3, title: "Task C", assigned: "Adam", importance: "High" },
{ id: 4, title: "Task D", assigned: "Mike", importance: "Medium" },
{ id: 5, title: "Task E", assigned: "Adam", importance: "High" },
{ id: 6, title: "Task F", assigned: "Adam", importance: "High" },
{ id: 7, title: "Task G", assigned: "Steve", importance: "Low" },
{ id: 8, title: "Task H", assigned: "Adam", importance: "High" },
{ id: 9, title: "Task I", assigned: "Adam", importance: "Low" },
{ id: 10, title: "Task J", assigned: "Mark", importance: "High" },
{ id: 11, title: "Task K", assigned: "Adam", importance: "Medium" },
{ id: 12, title: "Task L", assigned: "Adam", importance: "High" },
];
And weâll render these tasks with this markup:
<div>
{#each tasks as t}
<div>
<div>
<span>{t.id + getCounter()}</span>
<button onclick={() => (t.id += 10)} class="border p-2">Update id</button>
</div>
<div>
<span>{t.title + getCounter()}</span>
<button onclick={() => (t.title += 'X')} class="border p-2">Update title</button>
</div>
<div>
<span>{t.assigned + getCounter()}</span>
<button onclick={() => (t.assigned += 'X')} class="border p-2">Update assigned</button>
</div>
<div>
<span>{t.importance + getCounter()}</span>
<button onclick={() => (t.importance += 'X')} class="border p-2">Update importance</button>
</div>
</div>
{/each}
</div>
The Svelte 4 code weâll start with uses theon:click
syntax for events, but everything else will be the same.
The calls togetCounter
inside the bindings will let us see when those bindings are re-executed, since the call togetCounter()
will always return a new value.
Letâs get started!
Svelte 4
Weâll render the content we saw above, using Svelte 4.

Plain and simple. But now letâs click any of those buttons, to modify one property, of one of those tasksâit doesnât matter which.

Notice that the entire component (every binding in the component) re-rendered. As inefficient as this seems, itâsstillmuch better than what React does. Itâs not remotely uncommon for a single state update to triggermultiplere-renders ofmanycomponents.
Letâs see how Svelte 5 improves things.
Svelte 5
For Svelte 5, the code is pretty much the same, except we declare our state like this:
let tasks = $state([
{ id: 1, title: "Task A", assigned: "Adam", importance: "Low" },
<em>// and so on ...</em>
{ id: 12, title: "Task L", assigned: "Adam", importance: "High" },
]);
We render the page, and see the same as before. If youâre following along in the repo, be sure to refresh the page after navigating, so the page will start over with the global counter.

Now letâs change one piece of state, as before. Weâll update the title for Task C, the third one.

Just like that, only the single piece of state we modified has re-rendered. Svelte was smart enough to leave everything else alone. 99% of the time this wonât make any difference, but if youâre rendering alotof data on a page, this can be a substantial performance win.
Why did this happen?
This is the default behavior when we pass arrays and objects (and arrays of objects) into the$state
rune, like we did with:
let tasks = $state([
Svelte will read everything you pass, set upProxyobjects to track what changes, and update the absolute minimum amount of DOM nodes necessary.
False Coda
We could end the post here. Use the$state
primitive to track your reactive data. Svelte will make it deeply reactive, and update whatever it needs to update when you change anything. This will bejust finethe vast majority of the time.
But what if youâre writing a web application that has to manage atonof data? Making everything deeply reactive is not without cost.
Letâs see how we can tell Svelte that onlysome ofour data is reactive. Iâll stress again, laboring over this will almost never be needed. But itâs good to know how it works if it ever comes up.
Rediscovering a long-lost JavaScript feature
Classes in JavaScript have gotten an unfortunately bad reputation. Classes are an outstanding way to declare thestructureof a set of objects, which also happen to come with a built-in factory function for creating those objects. Not only that, but TypeScript is deeply integrated with them.
You can declare:
class Person {
firstName: string;
lastName: string;
constructor(firstName: string, lastName: string) {
this.firstName = firstName;
this.lastName = lastName;
}
}
Not only will this provide you a factory function for creating instances of a Person, vianew Person('Adam', 'Rackis')
, butPerson
can also be used as a type within TypeScript. You can create variables or function parameters of typePerson
. Itâs one of the few things that exist as a runtime construct and also a TypeScript type.
That said, if you find yourself reaching forextends
in order to create deep inheritance hierarchies with classes, please please re-think your decisions.
Anyway, why am I bringing up classes in this post?
Fine-grained reactivity in Svelte 5
If you have a performance-sensitive section of code where you need to mark some properties as non-reactive, you can do this by creating class instances rather than vanilla JavaScript objects. Letâs define a Task class for our tasks. For the properties wewant tobe reactive, weâll set default values with the$state()
rune. For properties wedonâtwant to be reactive, we wonât.
class Task {
id: number = 0;
title = $state("");
assigned = $state("");
importance = $state("");
constructor(data: Task) {
Object.assign(this, data);
}
}
And then we just use that class
let tasks = $state([
new Task({ id: 1, title: "Task A", assigned: "Adam", importance: "Low" }),
<em>// and so on</em>
new Task({ id: 12, title: "Task L", assigned: "Adam", importance: "High" }),
]);
I simplified the class a bit by taking a raw object with all the properties of the class, and assigning those properties withObject.assign
. The object literal is typed in the constructor asTask
, the same as the class, but thatâs fine because of TypeScriptâsstructural typing.
When we run that, weâll see the same exact thing as before, except clicking the button to change the id
will not re-render anything at all in our Svelte component. To be clear, theid
is still changing, but Svelte is not re-rendering. This demonstrates Svelte intelligently not wiring any kind of observability into that particular property.
Side note: if you wanted to encapsulate / protect theid
, you could declare id
as#id
to make it aprivate propertyand then expose the value with a getter function.
Going deeper
What if you donât want these tasks to be reactive at the individual property at all? What if we have alotof these tasks coming down, and youâre not going to be editing them? So rather than have Svelte set up reactivity for each of the tasksâ properties, you just want the array itself to be reactive.
You basically want to be able to add or remove entries in your array, and have Svelte update the tasks that are rendered. But you donât want Svelte setting up any kind of reactivity for each property on each task.
This is a common enough use case that other state management systems support this directly, for example, MobXâsobservable.shallow
. Unfortunately Svelte does not have any such helper, as of yet. That said, itiscurrently being debated, so keep your eyes open for a$state.shallow()
that would do what weâre about to show. But even if it does get added, implementing it ourselves will be a great way to kick the tires of Svelteâs new reactivity system. Letâs see how.
Implementing our own $state.shallow()
equivalent
We already saw how passing class instances to an array shut off fine-grained reactivity by default, leaving you to opt-in, as desired, by setting class fields to$state()
. But our data are likely coming from a database, as plain (hopefully typed) JavaScript objects, unrelated to any class; more importantly we likely have zero desire to cobble together a class just for this.
So letâs simulate it. Letâs say that a database is providing our Task objects as JS objects. We (of course) have a type for this:
type Task = {
id: number;
title: string;
assigned: string;
importance: string;
};
We want to put those instances into an array that itself is reactive, but not the individual properties on the tasks. With a tiny bit of cleverness we can make it mostly painless.
class NonReactiveObjectGenerator {
constructor(data: unknown) {
Object.assign(this, data);
}
}
function shallowObservable<T>(data: T[]): T[] {
let result = $state(data.map(t => new NonReactiveObjectGenerator(t) as T));
return result;
}
OurNonReactiveObjectGenerator
class takes in any object, and then smears all that objectâs properties onto itself. And ourshallowObservable
takes an array of whatever, and maps it onto instances of ourNonReactiveObjectGenerator
class. This will force each instance to be a class instance, with nothing reactive. Theas T
is us forcing TypeScript to treat these new instances as whatever type was passed in. This is accurate, but something TypeScript needs help understanding, since itâs not (as of now) able to read and understand our call toObject.assign
in the class constructor.
If you closely read myfirst poston Svelte 5, you might recall that you canât directly return reactive state from a function, since the state will be read and unwrapped right at the call-site, and wonât be reactive any longer. Normally youâd have to do this:
return {
get value() {
return result;
},
set value(newData: T[]) {
result = newData;
},
};
Why wasnât that needed here? Itâs true, the$state()
value will be read at the functionâs call site. So withâŠ
let tasks = shallowObservable(getTasks());
âŠthe tasksvariablewill not be reactive. But the array itself will still be fully reactive. We can still call push
, pop
, splice
and so on. If you can live without needing to re-assign to the variable, this is much simpler. But even if you do need to set the tasks variable to a fresh array of values, you still donât even need to use variable assignment. Stay tuned.
I changed the initial tasks array to help out in a minute, but the rest is what youâd expect.
const getTasks = () => [
{ id: 1, title: "Task A", assigned: "Adam", importance: "Low" },
<em>// ...</em>
{ id: 12, title: "Task L", assigned: "Adam", importance: "High" },
];
let tasks = shallowObservable(getTasks());
And with that, rendering should now work, and none of our properties are reactive. Clicking the edit buttons do nothing.
But we can now add a button to push a new task onto our array.
<button
onclick={() =>
tasks.value.push(
new NonReactiveObjectGenerator({
id: nextId++,
title: 'New task',
assigned: 'Adam',
importance: 'Low'
}) as Task
)}
>
Add new task
</button>
We can even add a delete button to each row.
<button onclick={() => tasks.value.splice(idx, 1)}>
Delete
</button>
Yes, Svelteâs reactive array is smart enough to understand push and splice.
Editing tasks this way
You might be wondering if we can still actually edit the individual tasks. We assumed the tasks would be read-only, but what if that changes? Weâve been modifying the array and watching Svelte re-render correctly. Canât we edit an individual task by just cloning the task, updating it, and then re-assigning to that index? The answer is yes, with a tiny caveat.
Overriding an array index (with anewobject instance) does work, and makes Svelte update. But we canât just do this:
tasks[idx] = { ...t, importance: "X" + t };
Since that would make the new object, which is an object literal, deeply reactive. We have to keep using our class. This time, to keep the typings simple, and to keep the code smell that is the NonReactiveObjectGenerator
class hidden as much as possible, I wrote up a helper function.
function cloneNonReactive<T>(data: T): T {
return new NonReactiveObjectGenerator(data) as T;
}
As before, the type assertion is unfortunately needed. This same function could also be used for the add function we saw above, if you prefer.
To prove editing works, weâll leave the entire template alone, except for theimportance
field, which weâll modify like so
<div>
<span>{t.importance + getCounter()}</span>
<button
onclick={() => {
const taskClone = cloneNonReactive(t);
taskClone.importance += 'X';
tasks[idx] = cloneNonReactive(taskClone);
}}
>
Update importance
</button>
</div>
Now running shows everything as itâs always been.

If we click the button to change the id, title or assigned value, nothing changes, because weâre still mutating those properties directly (since I didnât change anything) in order to demonstrate that theyâre not reactive. But clicking the button to update the importance field runs the code above, and updates theentirerow, showing any other changes weâve made.
Here I clicked the button to update the title, twice, and then clicked the button to update the importance. The former did nothing, but the latter updated the component to show all changes.

tasks array
We saved a bit of convenience by returning our state value directly from our shallowObservable
helper, but at the expense of not being able to assign directly to our array. Or did we?
If you know a bit of JavaScript, you might knowâŠ
tasks.length = 0;
âŠis the old school way to clear an array. That works with Svelte; the Proxy object Svelte sets up to make our array observable works with that. Similarly, we can set the array to a fully new array of values (after clearing it like we just saw) like this:
tasks.push(...newArray);
Itâs up to you which approach you take, but hopefully Svelte ships a$state.shallow
to provide the best of both worlds: the array would be reactive, and so would the binding, since we donât have to pass it across a function boundary; it would be built directly into$state
.
SvelteKit
Letâs wrap up by briefly talking about how data from SvelteKit loaders is treated in terms of reactivity. In short, itâs exactly how youâd expect. First and foremost, if you return a raw array of objects from your loader like this:
export const load = () => {
return {
tasks: [
{ id: 1, title: "Task A", assigned: "Adam", importance: "Low" },
<em>// ...</em>
{ id: 12, title: "Task L", assigned: "Adam", importance: "High" },
],
};
};
Then none of that data will be reactive in your component. This is to be expected. To make data reactive, you need to wrap it in$state()
. As of now, you canât call$state
in a loader, only in a universal Svelte file (something that ends in.svelte.ts
). Hopefully in the future Svelte will allow us to have loaders named+page.svelte.ts
but for now we can throw something like this in areactive-utils.svelte.ts
file.
export const makeReactive = <T>(arg: T[]): T[] => {
let result = $state(arg);
return result;
};
Then import it and use it in our loader.
import { makeReactive } from "./reactive-utils.svelte";
export const load = () => {
return {
tasks: makeReactive([
{ id: 1, title: "Task A", assigned: "Adam", importance: "Low" },
<em>// ...</em>
{ id: 12, title: "Task L", assigned: "Adam", importance: "High" },
]),
};
};
Now those objects will support the same fine-grained reactivity we saw before. To customize which properties are reactive, youâd swap in class instances, instead of vanilla object literals, again just like we saw. All the same rules apply.
If youâre wondering why we did thisâŠ
export const makeReactive = <T>(arg: T[]): T[] => {
let result = $state(arg);
return result;
};
âŠrather than thisâŠ
export const makeReactive = <T>(arg: T[]): T[] => {
return $state(arg);
};
⊠the answer is that the latter is simply disallowed. Svelte forces you to only put$state()
calls into assignments. It cannot appear as a return value like this. The reason is that while returning $state variables directly across a function boundary works fine for objects and arrays, doing this for primitive values (strings or numbers) would produce a senseless result. The variable could not be re-assigned (same as we saw with the array), but as a primitive, thereâd be no other way to edit it. It would just be a non-reactive constant.
Svelte forcing you to take that extra step, and assign $state to a variable before returning, is intended to help prevent you from making that mistake.
Wrapping up
One of the most exciting features of Svelte 5 is the fine-grained reactivity it adds. Svelte was already lightweight, and faster than most, if not all of the alternatives. These additions in version 5 only improve on that. When added to the state management improvements weâve already covered in prior posts, Svelte 5 really becomes a serious framework option.
Consider it for your next project.
Article Series