Keeping your application in sync with its state is one of the most important features that a framework can provide. In this post, we'll learn about how reactivity works in Svelte, and avoid common issues when using it.
Let's start a new application to explain how it works.
npm init @vitejs/app
✔ Project name: · svelte-reactivity
✔ Select a framework: · svelte
✔ Select a variant: · svelte-ts
cd svelte-reactivity
pnpm install //use the package manager you prefer
pnpm run dev
We will remove everything we have in our App.svelte
component an replace it with the following:
<!-- App.svelte -->
<script lang="ts">
let language: 'es'|'en' = 'en';
function toggleLanguage() {
language = language === 'en' ? 'es' : 'en';
}
</script>
<main>
<p>{language}</p>
<button on:click={toggleLanguage}>Toggle Language</button>
</main>
We added a button with an event handler responsible for toggling our variable with two values en
and es
.
We can see that the value is updated every time we click the button.
In Svelte, the DOM is updated when an assignment is made. In this example, language
is assigned with the result of language === 'en' ? 'es' : 'en'
.
Behinds the scenes, Svelte will take care of rerendering the value of language when the assignment happens.
If we take a look at the compiled code we will find this.
/* App.svelte generated by Svelte v3.38.3 */
// ...
function instance($$self, $$props, $$invalidate) {
let language = "en";
function toggleLanguage() {
$$invalidate(0, language = language === "en" ? "es" : "en");
}
return [language, toggleLanguage];
}
// ...
We can see that our toggleLanguage
function looks a bit different, wrapping the assignment with the $$invalidate
method.
Let's make a few more changes to our file to see how assignment affects reactivity and rerendering.
<!-- App.svelte -->
<script lang="ts">
let testArray = [0]
function pushToArray(){
testArray.push(testArray.length)
}
function assignToArray(){
testArray = [...testArray, testArray.length]
}
</script>
<main>
<p>{testArray}</p>
<button on:click={pushToArray}>Push To Array</button>
<button on:click={assignToArray}>Assign To Array</button>
</main>
Whenever we click on the Assign To Array
Button, the DOM is updated with the new value.
When we try to get the same result by mutating the array, the DOM is not updated, but the app state is. We can verify that when we later click the Assignment button and the DOM is updated, showing the actual state of testArray
.
Let's inspect the generated code once again.
function instance($$self, $$props, $$invalidate) {
let testArray = [0];
function pushToArray() {
testArray.push(testArray.length);
}
function assignToArray() {
$$invalidate(0, testArray = [...testArray, testArray.length]);
}
return [testArray, pushToArray, assignToArray];
}
If you compare both functions, we can now see that only the assignment will call the $$invalidate
method, while the other one calls the expression as is.
This doesn't mean we cannot mutate arrays and force a rerender. We need to use an assignment after mutation to do it.
<!-- App.svelte -->
<script lang="ts">
//...
function pushToArray(){
testArray.push(testArray.length)
testArray = testArray
}
//...
</script>
Our complied function will be updated to:
function pushToArray() {
testArray.push(testArray.length);
$$invalidate(0, testArray);
}
which will update the DOM when called($$invalidate
method wraps the expression, that is simplified to testArray
instead of testArray = testArray
)
Reactive Variables
Imagine our team decided that we need to add a second array where each value is squared. If we were doing it imperatively, this would mean we need to update the second array each time the first one changes.
The previous example would look like this.
<!-- App.svelte -->
<script lang="ts">
let testArray = [0]
let squared = [0]
function pushToArray(){
testArray.push(testArray.length)
testArray = testArray
squared = testArray.map(value => value*value)
}
function assignToArray(){
testArray = [...testArray, testArray.length]
squared = testArray.map(value => value*value)
}
</script>
<main>
<p>{testArray}</p>
<p>{squared}</p>
<!-- ... -->
</main>
If we check the generated code again, we'll see that we are invalidating both arrays every time.
function pushToArray() {
testArray.push(testArray.length);
$$invalidate(0, testArray);
$$invalidate(1, squared = testArray.map(value => value * value));
}
function assignToArray() {
$$invalidate(0, testArray = [...testArray, testArray.length]);
$$invalidate(1, squared = testArray.map(value => value * value));
}
Unfortunately, this approach has a problem. We need to keep track of every place where testArray
is modified, and also update the squared
array.
If we think about this problem reactively, we only need to listen to changes in testArray
.
In Svelte, there's a special way to do this. Instead of declaring a variable with let
, we will use $:
. This is a labeled statement (it's valid JS), and it's used by the compiler to let it know that a reactive variable is being declared, and it depends on all the variables that are added to the expression.
In our example:
<script lang="ts">
let testArray = [0];
$: squared = testArray.map(value => value * value)
function pushToArray() {
testArray.push(testArray.length);
testArray = testArray;
}
function assignToArray() {
testArray = [...testArray, testArray.length];
}
</script>
Using this reactive approach, we need to handle changes to testArray
exclusively.
The compiler will detect that there's a dependency in testArray
to calculate the actual value of squared
.
If you run the app again, the same behavior is achieved.
How did this happen?
Let's look at our compiled code.
$$self.$$.update = () => {
if ($$self.$$.dirty & /*testArray*/ 1) {
$: $$invalidate(1, squared = testArray.map(value => value * value));
}
};
The internal property update
is now assigned to a function that will check if the instance has changed, and that invalidate squared
if the condition is met.
Every other reactive variable we add to our component will add a new block that will check if a dependency changed, and invalidate the declared variable.
For example:
<script lang="ts">
let testArray = [0];
let multiplier = 5
$: squared = testArray.map(value => value * value)
// if ($$self.$$.dirty & /*testArray*/ 1) {
// $: $$invalidate(1, squared = testArray.map(value => value * value));
// }
$: squaredTwice = squared.map(value => value * value)
// if ($$self.$$.dirty & /*squared*/ 2) {
// $: squaredTwice = squared.map(value => value * value);
// }
$: multiplied: squaredTwice.map(value => value * multiplier)
// if ($$self.$$.dirty & /*squaredTwice, multiplier*/ 34) {
// $: multiplied = squaredTwice.map(value => value * multiplier);
// }
</script>
<!-- ... -->
The last declaration, however, depends on two variables, squareTwice
and multiplier
. You can tell by the comment in the if
condition.
Our updated component now looks like this:
<script lang="ts">
let testArray = [0];
let multiplier = 5;
$: squared = testArray.map((value) => value * value);
$: squaredTwice = squared.map((value) => value * value);
$: multiplied = squaredTwice.map((value) => value * multiplier);
function pushToArray() {
testArray.push(testArray.length);
testArray = testArray;
}
function assignToArray() {
testArray = [...testArray, testArray.length];
}
</script>
<main>
<p>{testArray}</p>
<p>{squared}</p>
<p>{squaredTwice}</p>
<p>{multiplied}</p>
<button on:click={pushToArray}>Push To Array</button>
<button on:click={assignToArray}>Assign To Array</button>
<button on:click={() => multiplier = multiplier + 1}>Multiplier</button>
</main>
I added a button to add 1 to multiplier
to verify that the multiplied
array is also depending on it.
Reactive Statements
Reactivity is not limited to variable declarations. Using the same $:
pattern we can create reactive statements.
For example, we could add an if statement or add a try-catch block.
Let's try the following:
<script lang="ts">
//...
let error = null;
//...
$: try {
if (multiplier > 8) {
throw 'boo';
}
} catch (e) {
error = e;
}
//...
</script>
<main>
<!-- ... -->
{#if error}
<p>{error}</p>
{/if}
<!-- ... -->
</main>
Looking at the generated code we can see the same pattern as before:
if ($$self.$$.dirty & /*multiplier*/ 2) {
$: try {
if (multiplier > 8) {
throw "boo";
}
} catch(e) {
$$invalidate(4, error = e);
}
}
The compiler recognizes how the statement depends on changes to multiplier
and that invalidating error
is a possibility.
Store auto-subscription
A store is defined as an object that implements the following contract (at a minimum):
store = { subscribe: (subscription: (value: any) => void) => (() => void), set?: (value: any) => void }
Stores are beyond the scope of this post but they will make possible to listen for changes to a piece of your app state.
Then, we can translate this event (when the store emits a new value) into an assignment that, as we mentioned before, will update our DOM.
For example:
// stores.ts
import { writable } from 'svelte/store';
export const storeArray = writable([0]);
<!-- App.svelte -->
<script lang="ts">
import { onDestroy } from 'svelte';
import { storeArray } from './stores';
let testArray;
const unsubscribe = storeArray.subscribe((value) => {
testArray = value;
});
function addValueToArray() {
storeArray.update((value) => [...value, value.length]);
}
onDestroy(unsubscribe);
</script>
<main>
<p>{testArray}</p>
<button on:click={addValueToArray}>Add Value</button>
</main>
Whenever we update
, or set
our store, a new value will be emitted and assigned to testArray
.
We can confirm that we are calling $$invalidate
in the compiled code.
const unsubscribe = storeArray.subscribe(value => {
$$invalidate(0, testArray = value);
});
But there's another way to achieve this with auto-subscriptions.
Our component now becomes this:
<script lang="ts">
import { storeArray } from './stores';
function addValueToArray() {
storeArray.update((value) => [...value, value.length]);
}
</script>
<main>
<p>{$storeArray}</p>
<button on:click={addValueToArray}>Add Value</button>
</main>
Looking at auto subscriptions. There's no assignment in it, but our DOM is updated when we update the array. How is this achieved?
Let's analyze the output code:
function instance($$self, $$props, $$invalidate) {
let $storeArray;
component_subscribe($$self, storeArray, $$value => $$invalidate(0, $storeArray = $$value));
function addValueToArray() {
storeArray.update(value => [...value, value.length]);
}
return [$storeArray, addValueToArray];
}
We can see that we are calling component_subscribe
with three parameters: the component, the store, and a callback function, which is invalidating our $storeArray
variable.
If we go deeper and check what component_subscribe
is doing underneath, we'll find the following:
export function subscribe(store, ...callbacks) {
if (store == null) {
return noop;
}
const unsub = store.subscribe(...callbacks);
return unsub.unsubscribe ? () => unsub.unsubscribe() : unsub;
}
export function component_subscribe(component, store, callback) {
component.$$.on_destroy.push(subscribe(store, callback));
}
... Which is doing the same as the original code.
It subscribes to the store, and returns a unsubscribe method (or an object with an unsubscribe method), and calls it when the component is destroyed. When a new value is emitted, the callback is executed ($$invalidate
), assigning the emitted value to the auto-subscribe variable.
Common issues
Remember that we need an assignment to call
$$invalidate
mark the component instance as dirty and run all the checks.
=, ++, --, +=, -=
are all considered assignments.When working with objects, the assignment must include the name of the variable referenced in the template.
For example:
<script>
let foo = { bar: { baz: 1 } };
let foo2 = foo;
function addOne() {
foo2.bar.baz++;
}
function refreshFoo() {
foo = foo;
}
</script>
<p>foo: {JSON.stringify(foo, null, 2)}</p>
<p>foo2: {JSON.stringify(foo2, null, 2)}</p>
<button on:click={addOne}> add 1 </button>
<button on:click={refreshFoo}> Refresh foo </button>
When adding 1 to foo2.bar.baz
the compiler only knows that it must update references to foo2
in the templates, but it will not update references to foo
event if it changes too (they're the same object). When calling refreshFoo
we are manually invalidating foo
- when mutating arrays, be mindful of adding an assignment at the end to let the compiler know that it must update the template references.
Wrapping up
In general, whenever an assignment is made it will compile into an $$invalidate
method that will mark the component as dirty and apply the required changes to the DOM.
If there's any reactive variable (or statement) it will check if the component is marked as dirty and if any of its dependencies changed (because it has been invalidated), if that's the case then it will also invalidate it.
Store auto subscription creates an assignment that invalidates the $
prepended variable when the store emits a new value.
This Dot Labs is a modern web consultancy focused on helping companies realize their digital transformation efforts. For expert architectural guidance, training, or consulting in React, Angular, Vue, Web Components, GraphQL, Node, Bazel, or Polymer, visit thisdotlabs.com.
This Dot Media is focused on creating an inclusive and educational web for all. We keep you up to date with advancements in the modern web through events, podcasts, and free content. To learn, visit thisdot.co.
Top comments (0)