DEV Community

Cover image for Svelte Reactivity: Let's Talk About $Effects
José Pablo Ramírez Vargas
José Pablo Ramírez Vargas

Posted on • Originally published at webjose.hashnode.dev

Svelte Reactivity: Let's Talk About $Effects

In the previous article of the series, we learned the basics of Svelte v5’s reactivity system: The basic runes. We learned that this version of Svelte now reacts to things in runtime rather than depending on the static code analysis provided in previous versions. But what does this mean? Let’s find out.

The $effect Rune in Detail

Before we can talk about effects, let’s remind us about proxies in JavaScript: Proxies are objects that take the place of other objects in order to intercept calls to its methods and properties. This is what goes on in Svelte v5 when you use reactivity: Things like $state create proxies for you. These proxies are then programmed in such a way that they can “report” to a central location in the Svelte runtime whenever they are being read (their value is being accessed). This is the basis of effects, and reactivity overall.

Whenever we create an effect with the $effect rune, we are setting up a “recorder” of reactive value reads. Let’s see an example:

<script lang="ts">
    let count = $state(0);

    $effect(() => {
        if (count >= 5) {
            window.open('/some/url');
        }
    });
</script>

<button type="button" onclick={() => ++count}>
    Click me
</button>
Enter fullscreen mode Exit fullscreen mode

The component above sets an effect up in such a way that a pop-up comes up whenever the button is clicked 5 or more times. The first line of the effect’s code (the IF statement) reads the reactive variable count and the recorder picks up this fact. Now the Svelte runtime knows that whenever the count variable’s value changes, the effect must be re-run because it depends on this reactive variable’s value.

This recording takes place every time the effect is run: The previous set of recorded reactive values (if any) is discarded, and a new recording takes place, every time the effect is re-run.

IMPORTANT: The above is critical. Debugging effects is tricky and knowing the above will help you heaps if you ever need to figure out why an effect is not running when (you believe) it should.

So, there you go, one of the most vital pieces of information about effects just fell on your lap.

The $inspect.trace() Rune

This is a new rune available in Svelte v5.14.0 and above and is meant to help troubleshoot effects and derived values. When used inside the function body of an $effect or a $derived.by rune, it outputs the reactive signals that were read during the run. Furthermore, the signals that changed and made the effect to re-run will be highlighted in light blue (colorblind people, kindly file an issue with Svelte because this color indicator is the only indicator available). On first run, all signals will be light blue.

IMPORTANT: The documentation about this rune is plain wrong, in my opinion. It reads “information will be printed to the console about which pieces of reactive state caused the effect to fire.”, which is not true. It should read “information about the read signals will be printed to the console, with the pieces of reactive state that caused the effect to fire highlighted in light blue color.”.

To give you an idea of how the output looks like, I added the line $inspect.trace(‘seggregatedColumns’); to the calculation of the seggregatedColumns value in @wjfe/dataview and this is the initial console output:

Example output of the $inspect.trace() rune

As numbered in the screenshot:

  • #1: It shows the amount of time taken to complete the operation.
  • #2, #3: It shows the type of reactive signal.
  • #4, #5: It shows the reactive signal’s value.

Watch-Outs!

There are a few cases worth noting where you might have trouble “seeing” because they are not immediately obvious.

Branching

One of the more common gotcha’s is to think that an effect will run based on a reactive value, but this is not happening because the value wasn’t really read the last (first) time the effect executed.

This next example uses a “control” Boolean variable that disallows the running of the effect the very first time the effect is run, maybe because you want to save an HTTP request, or perhaps double rendering something, etc. For whatever reason.

<script lang="ts">
    let firstRun = true;
    let someReactiveState = $state(0);

    $effect(() => {
        if (firstRun) {
            firstRun = false;
            return;
        }
        // Rest of effect here where "someReactiveState" is used (read).
    });
</script>
Enter fullscreen mode Exit fullscreen mode

What will happen here is that the effect is run (for the first time once the component mounts) and will record any reads on reactive values. But guess what? The guard that was set in place using the firstRun variable had prevented any reads of reactive variables. The result: The effect will never run again.

You might think that this is fixed by making firstRun a reactive variable. This way, when it changes to false the effect runs again. But if you do that, the guard has no meaning: You ended up running the effect at the moment the component was mounted. You gained nothing. Let me know in the Comments section if this is not making sense to you and I’ll try to explain better.

The best fix for this is to avoid this construct. If you must, however, the way to fix it is to make dummy reads of the desired reactive values before the first-run guard:

<script lang="ts">
    let firstRun = true;
    let someReactiveState = $state(0);

    $effect(() => {
        if (firstRun) {
            // Dummy read:
            someReactiveState;
            firstRun = false;
            return;
        }
        // Rest of effect here where "someReactiveState" is used (read).
    });
</script>
Enter fullscreen mode Exit fullscreen mode

Short Circuits

JavaScript will short-circuit when calculating Boolean expressions. If the expression requires evaluation of N sub-expressions, but evaluating the first K expressions is enough to infer the result, the rest of expressions R (N - K) will not be evaluated. They have been excluded because the entire original expression’s value has been inferred already.

Let’s see an example:

<script lang="ts">
    let a = $state(false);
    let b = $state(true);
    let c = $state(0);

    $effect(() => {
        if (a && b) {
            // Some logic that reads reactive value "c".
        }
    });
</script>
Enter fullscreen mode Exit fullscreen mode

The example above contains the Boolean expression a && b. As you can verify by looking at a and b’s initial values, the expression doesn’t require reading the value of b: The result of a && b can be inferred by just reading a and realizing that it is false. Since false && <anything> will be false, there is no need to read b’s value. This means that the effect’s recorder will not record b as a potential trigger for the effect.

The net result of the above: Unless the value of a changes, changes to b won’t trigger the effect. This is particularly problematic if one of the K expressions (the expressions that are read/evaluated) are based on non-reactive variables.

Also note that, whenever a and b are true, the effect’s recorder will also record that c is a dependency of the effect. This should be clear by now, but one last reminder just in case. The recording takes place every time the effect runs, so this effect sometimes will only be triggered by changes in a, a or b, and sometimes it will be a, b or c.

The main lesson here is: The order of the expressions that compose Boolean expression matter.

Reading State Without Being Recorded

In less common cases, you might want to read a reactive variable inside an effect, but you don’t want the effect re-run if that reactive variable’s value changes. This can be accomplished with the untrack function. Any reactive variables read within the function delegate given to untrack will not be recorded and therefore will not contribute to the effect’s re-run.

The untrack function is usually the one function that saves you from infinite loops in effects that read and write the same reactive variable, or when 2-way synchronization is needed.

As stated, 2-way synchronization is usually a case where untrack is needed. The following example synchronizes a filter user interface with a quick-search textbox (that assumes to work on the “name” field):

<script lang="ts">
    import { untrack } from "svelte";

    const fields = ['name', 'email'];

    const stringOperators = ['contains', 'equals', 'starts with', 'ends with'];
    type Condition = {
        field?: string;
        value?: string;
        operator?: (typeof stringOperators)[number];
    };

    let filter = $state<Condition[]>([]); // Internal filter structure
    let quickSearch = $state(''); // Quick search value

    // Effect that transfers the quick search to the filter.
    // We need to untrack any reads on the filter to avoid infinite loops,
    // or at least unnecessary effect runs.
    $effect(() => {
        const cndIndex = untrack(() => filter.findIndex(condition => condition.field === 'name'));
        if (quickSearch) {
            if (cndIndex >= 0) {
                filter[cndIndex].value = quickSearch;
            } else {
                untrack(() => filter).push({ field: 'name', operator: 'contains', value: quickSearch });
            }
        }
        else if (cndIndex >= 0) {
            filter.splice(cndIndex, 1);
        }
    });
    // Effect that updates the quick search when the filter changes.
    $effect(() => {
        const nameCondition = filter.find(condition => condition.field === 'name');
        quickSearch = nameCondition?.value ?? '';
    });
</script>

<label>
    Quick Search:
    <input type="text" />
</label>

<form>
    {#each filter as _, index}
        <div>
            <select bind:value={filter[index].field}>
                {#each fields as field}
                    <option value={field}>{field}</option>
                {/each}
            </select>
            <select bind:value={filter[index].operator}>
                {#each stringOperators as operator}
                    <option value={operator}>{operator}</option>
                {/each}
            </select>
            <input type="text" bind:value={filter[index].value} placeholder="Value" />
            <button type="button" onclick={() => (filter = filter.filter((_, i) => i !== index))}>Remove</button>
        </div>
    {/each}
    <button
        type="button"
        onclick={() => filter.push({ field: fields[0], operator: stringOperators[0], value: '' })}
    >
        Add Condition
    </button>
</form>
Enter fullscreen mode Exit fullscreen mode

The example is a bit long, but I felt it necessary to provide a good real-life example.

Warning: GitHub Copilot generated the markup and a few things in the script. I did not review it all. I did, however, review the 2 effects in the script tag.

The main thing to note here is that the first effect untracks any reads on the filter signals to avoid being re-run whenever the filter changes. This brings this tip up: It is very useful to add a comment line that describes what the effect’s intention is. This helps you study the code and be mindful about untracking any potential problematic signals.

The above also brings another tip up: The more focused an effect is, the better. Try to ensure that an effect has one and only one purpose. It is better to write more effects that does minimal things than one big effect that does all.

What About $effect.pre?

This is identical to the $effect rune in every way except one: Effects registered using $effect.pre will run before the component is mounted and before changes to the HTML document are made (the $effect ones run after mounting and after updates). That’s all there is to say about this rune.

Using Runes Outside Components

This is not an $effect-exclusive feature, but it is convenient to learn it in the context of effects because effects are the magic behind everything.

One of the major drivers for creating Svelte v5 with proxies (reactive signals) was that they allow for code refactoring. Rich Harris has explained this extensively, so I won’t say much about this here.

What’s important right now is to know that runes can be used outside component files as long as their file name contains “.svelte” in it, so the Svelte preprocessor picks these files up.

The simplest example is to replace Svelte v4 stores:

// v5Writable.svelte.ts
// Function form.  Not the preferred variant.
export function v5Writable<T>(initialValue: T) {
    const store = $state({ value: initialValue });
    return store;
}
// Class form.  This is the preferred variant.
export class V5State<T> {
    value = $state<T>() as T; // The type assertion is a trick to get rid of undesired "undefined".
    constructor(initialValue: T) {
        this.value = initialValue;
    }
}
Enter fullscreen mode Exit fullscreen mode

Remember the previous article of this series: Even if $state looks like a function (that produces an R-value), it is not. It produces an L-Value with deep reactivity. We need the value property and so deep reactivity is welcome here.

Now you can use the v5Writable function or the V5State class in place of the old writable function to create stores based on proxies (signals):

<script lang="ts">
    import { V5State } from './v5Writable.svelte.js'
    let nameState = new V5State('world');
    let countState = new V5State(1);
</script>

<input type="text" bind:value={nameState.value} />
<input type="number" min="1" max="10" bind:value={countState.value} /> times
{#each { length: countState.value } as _, index}
    <h1>{index + 1}: Hello, {nameState.value}!</h1>
{/each}
Enter fullscreen mode Exit fullscreen mode

NOTE: To be actual stores that can be accessed by multiple components, you would export an instance created by the function or an instance of the class, so the instance is available to many components. You know, the same you do with Svelte v4 stores: Singletons.

Ok, this is great, but how do we do the same with $effect? Because, for example, you might now want to write an effect that saves the value of the store every time it changes.

Creating Effects Outside Component Files

This is tricky. Effects can only be registered in the context of the lifecycle of components. You can write $effect’s anywhere you want: Constructors of classes, property getters or setters, exported module functions, etc. It will compile. The problem comes in runtime. If the $effect statement is executed outside the lifecycle of a component, you’ll get a runtime error.

Let’s modify V5State:

// v5Writable.svelte.ts
export class V5State<T> {
    value = $state<T>() as T;
    constructor(initialValue: T) {
        this.value = initialValue;
        $effect(() => {
            console.log('V5State value: %o', $state.snapshot(this.value));
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

After what I stated about how tricky effects are outside component files, this simple modification works! Did I blow things out of proportions? Well, let’s try to create singletons as one would for stores that need to be available to multiple components:

// NameStore.ts
import { V5State } from './v5Writable.svelte.js';

export default new V5State('World');
Enter fullscreen mode Exit fullscreen mode

Now consume this global instance as one would normally do: By importing it. This is the previous example consuming this global instance:

<script lang="ts">
    import { V5State } from './v5Writable.svelte.js'
    import nameState from './NameStore.js';
    let countState = new V5State(1);
</script>

<input type="text" bind:value={nameState.value} />
<input type="number" min="1" max="10" bind:value={countState.value} /> times
{#each { length: countState.value } as _, index}
    <h1>{index + 1}: Hello {nameState.value}!</h1>
{/each}
Enter fullscreen mode Exit fullscreen mode

The following error shows up:

effect_orphan `$effect` can only be used inside an effect (e.g. during component initialisation)
Enter fullscreen mode Exit fullscreen mode

This happens because the code that sets the effect up is now running before the component that uses it starts initializing. So No, I did not blow things out of proportion. This is a real tricky issue one must have in mind.

Let’s learn about how to properly code effects outside components.

Using $effect.tracking

The $effect.tracking rune is a function that returns true if, when executed, the code was running within an effect (actual effects set up with $effect, or the effect that takes care of the updates of the component template). It so happens that this is one of those times that we are within the context of a component’s lifecycle.

This is a useful piece of information that we can use to fix our current example:

export class V5State<T> {
    value = $state<T>() as T;
    constructor(initialValue: T) {
        this.value = initialValue;
        if ($effect.tracking()) {
            $effect(() => {
                console.log('V5State value: %o', $state.snapshot(this.value));
            });
        }
        else {
            console.log('Not tracking.');
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

With this new version, the error seen before disappears! Yay! Oh, but wait, examine the console: There are two “Not tracking.” messages, one for the global nameState singleton, and one for the countState store. The error went away, but the effect wasn’t really set up.

This is because $effect.tracking only tracks effects, but effects aren’t the only place where effects can be registered. Component initialization, as we saw before, is an acceptable time for registering effects. How can we get the effects registered, then?

I have raised this issue here, and the summary of the discussion is: There is no one API that can be used to accurately know when it is OK to register effects. Core team member trueadm expressed the fact that the core team has had this discussion before, and that they may (but not guaranteed to) come up with a new rune for this exact purpose. At the time of writing, this pull request defines the $effect.active rune for this purpose and is still a draft pull request.

In the meantime, us mortals must juggle things in userland for the time being.

To make things work, let’s allow developers to signal when the class is being instantiated during component initialization with an extra parameter:

export class V5State<T> {
    value = $state<T>() as T;
    constructor(initialValue: T, effectAllowed = false) {
        this.value = initialValue;
        if (effectAllowed || $effect.tracking()) {
            $effect(() => {
                console.log('V5State value: %o', $state.snapshot(this.value));
            });
        }
        else {
            console.log('Not tracking.');
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

We, as consumers of the V5State class, can now use this second parameter to get the effect going:

<script lang="ts">
    import { V5State } from './v5Writable.svelte.js'
    import nameState from './NameStore.js';
    let countState = new V5State(1, true);  <-----  HERE!
</script>

<input type="text" bind:value={nameState.value} />
<input type="number" min="1" max="10" bind:value={countState.value} /> times
{#each { length: countState.value } as _, index}
    <h1>{index + 1}: Hello {nameState.value}!</h1>
{/each}
Enter fullscreen mode Exit fullscreen mode

With this modification, we now only see one “Not tracking.” console log entry, and we can see that the effect for the count store works as expected.

To fix the global store, however, we must be more cunning than what we have been so far.

The $effect.root Rune

As stated in the documentation for this rune, it creates a non-tracked scope that doesn’t auto-cleanup. What does this mean? It means that you can run your own “private” effects whenever you want, at the cost of some extra RAM and CPU cycles.

We could re-write V5State like the following:

export class V5State<T> {
    value = $state<T>() as T;
    #cleanup;
    constructor(initialValue: T) {
        this.value = initialValue;
        this.#cleanup = $effect.root(() => {
            $effect(() => {
                console.log('V5State value: %o', $state.snapshot(this.value));
            });
        });
    }
    dispose() {
        this.#cleanup?.();
        this.#cleanup = undefined;
    }
}
Enter fullscreen mode Exit fullscreen mode

What’s the catch with this rune? Its clean-up. We have collected the clean-up function and have exposed the public method dispose() to be able to execute this clean-up. But when do we call it? When should we clean up? There is no good and single answer to this question, and if you take this route, this is something you’ll have to figure out for yourself depending on your particular use case.

The above variant, however, will properly run the effect whenever the value property changes, and is therefore one way to overcome the problem we have been talking about.

The createSubscriber Function

This is a function available since v5.7.0, and its documentation isn’t the easiest of the bunch. I’ll provide an alternative explanation.

The createSubscriber function is used to create a subscribing function that keeps track of the number of times a subscriber (a component) reads the reactive value of interest (in V5State’s case, its value property). Every time the value is read, it sets up an effect if the read is performed within a tracking context. In other words, if $effect.tracking() returns true. After this, it merely counts reads and reduces this count on subscriber unmounts. When the counter reaches zero again, everything is cleaned up.

At the time of writing, this is the recommended option from the Svelte core team.

Let’s re-write V5State one more time:

import { createSubscriber } from "svelte/reactivity";

export class V5State<T> {
    #value = $state<T>() as T;
    #subscribe;
    constructor(initialValue: T) {
        this.value = initialValue;
        this.#subscribe = createSubscriber(() => {
            $effect(() => {
                console.log('V5State value: %o', $state.snapshot(this.value));
            });
        });
    }
    get value() {
        this.#subscribe();
        return this.#value;
    }
    set value(newValue: T) {
        this.#value = newValue;
    }
}
Enter fullscreen mode Exit fullscreen mode

We have created the subscribing function, and we have set the effect up within the context of the subscription. We have also made the value property private and have explicitly provided a get accessor to ensure any reads on the value trigger a subscription.

Again: This is what the Svelte core team currently recommends.

This method has a scalability problem: This works by setting up a new effect every time the subscription runs (every time value is read), and these effects return a cleanup function that decrease the subscription count. Now, what if the value is read thousands of times in, say, an {#each} block? Then you will have created thousands of effects and thousands of cleanup functions.

There is also another problem: What if this is used in a global store (those where you export a singleton), and the subscription keeps starting and stopping, starting and stopping? If your subscription set-up is expensive, then you’ll pay the high price over and over.

Even if this is what the Svelte core team recommends being done, be aware that it might cause performance degradation in the above cases. In my opinion, avoid this method if your case is one of the above, and instead consider the $effect.root solution.

What About SSR?

This is a very easy question. Its answer: Effects don’t run in the server. In other words, server-side rendering doesn’t execute effects. Therefore, avoid effects as much as possible when writing applications or components that are meant to be rendered on the server. The effects only run after component hydration.

The lesson here is to always prefer $derived whenever possible because they work on the server.


Conclusion

Effects are very powerful but very tricky. Only by using them can one fully grasp their reach and limitations, and this article provides unique information that isn’t documented anywhere else.

Effects are great and necessary tools that enable the creation of great user interfaces, but there are rules to follow and behaviors to know in order to successfully realize their full potential. Developers need to understand how effects work internally in order to effectively code and debug them.

Svelte provide few, very useful tools to work with effects:

  1. $inspect.trace(): To debug reactivity issues.

  2. $effect.root(): To set effects up outside the context of components.

  3. createSubscriber: To assist in the creation of reusable reactive code, usually in the form of classes, that can set effects up.

There is still room for improvement. We are expecting the $effect.active rune to come out that should provide an alternative method that doesn’t exhibit the potential scalability issues of createSubscriber.

Top comments (0)