Skip to content

Reactivity in Svelte

This article was written over 18 months ago and may contain information that is out of date. Some content may be relevant but please refer to the relevant official documentation or available resources for the latest information.

Reactivity in Svelte

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>
Reactivity_Svelte_01

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>
Reactivity_Svelte_02
Reactivity_Svelte_03

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)

Reactivity_Svelte_04

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>
Reactivity_Svelte_05

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.

Reactivity_Svelte_06

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);
	});
Reactivity_Svelte_08

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 Reactivity_Svelte_07

  • 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 is a consultancy dedicated to guiding companies through their modernization and digital transformation journeys. Specializing in replatforming, modernizing, and launching new initiatives, we stand out by taking true ownership of your engineering projects.

We love helping teams with projects that have missed their deadlines or helping keep your strategic digital initiatives on course. Check out our case studies and our clients that trust us with their engineering.

Let's innovate together!

We're ready to be your trusted technical partners in your digital innovation journey.

Whether it's modernization or custom software solutions, our team of experts can guide you through best practices and how to build scalable, performant software that lasts.

Prefer email? hi@thisdot.co