Skip to content

Allow opt-in explicit dependency tracking for $effect #9248

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
ottomated opened this issue Sep 23, 2023 · 78 comments
Closed

Allow opt-in explicit dependency tracking for $effect #9248

ottomated opened this issue Sep 23, 2023 · 78 comments
Labels

Comments

@ottomated
Copy link
Contributor

ottomated commented Sep 23, 2023

Describe the problem

In Svelte 4, you can use this pattern to trigger side-effects:

<script>
  let num = 0;
  let timesChanged = 0;

  $: num, timesChanged++;
</script>

<h1>Num: {num}</h1>
<h2>Times changed: {timesChanged}</h2>
<button on:click={() => (num++)}>Increment</button>

However, this becomes unwieldy with runes:

<script>
  import { untrack } from 'svelte';
  let num = $state(0);
  let timesChanged = $state(0);
	
  $effect(() => {
    // I don't like this syntax - it's unclear what it's doing
    num;
    // If you forget the untrack(), you get an infinite loop
    untrack(() => {
      timesChanged++;
    });
  });
</script>

<h1>Num: {num}</h1>
<h2>Times changed: {timesChanged}</h2>
<button on:click={() => (num++)}>Increment</button>

Describe the proposed solution

Allow an opt-in manual tracking of dependencies:

(note: this could be a different rune, like $effect.explicit)

<script>
  let num = $state(0);
  let timesChanged = $state(0);
	
  $effect(() => {
    timesChanged++;
  }, [num]);
</script>

<h1>Num: {num}</h1>
<h2>Times changed: {timesChanged}</h2>
<button on:click={() => (num++)}>Increment</button>

Alternatives considered

  • Don't migrate to runes
  • Use the clunky solution above
  • This can almost be implemented by the user:
export function explicitEffect(fn, deps) {
  $effect(() => {
    deps;
    untrack(fn);
  });
}

but this compiles to

explicitEffect(
  () => {
    $.increment(timesChanged);
  },
  [$.get(num)]
);

Importance

would make my life easier

@dummdidumm
Copy link
Member

dummdidumm commented Sep 23, 2023

You can implement this in user land:

function explicitEffect(fn, depsFn) {
  $effect(() => {
    depsFn();
    untrack(fn);
  });
}

// usage
explicitEffect(
  () => {
     // do stuff 
  },
  () => [dep1, dep2, dep3]
);

@pbvahlst
Copy link

I use a similar pattern when e.g. a prop change, this would also benifit from being able to opt in on what to track instead of remembering to untrack everything.

export let data;
let a;
let b;

$: init(data);

function init(data) {
   a = data.x.filter(...);
   b = data.y.toSorted();
} 

I'm only interrested in calling init() when data change, not when a or b does, but a and b is used in the markup so they you still update the DOM.
Furthermore with $effect() this would not work for ssr if I understand correctly?

@ottomated
Copy link
Contributor Author

You can implement this in user land:

function explicitEffect(fn, depsFn) {
  $effect(() => {
    depsFn();
    untrack(fn);
  });
}

// usage
explicitEffect(
  () => {
     // do stuff 
  },
  () => [dep1, dep2, dep3]
);

Good approach. Don't love the syntax, but I suppose it's workable.

@ottomated
Copy link
Contributor Author

@pbvahlst

I think you want

const { data } = $props();
const a = $derived(data.x.filter(...));
const b = $derived(data.y.toSorted());

@pbvahlst
Copy link

Thanks @ottomated, that seems reasonable. The inverse of $untrack() would still make sense though as you initially suggested. Maybe something like

$track([dep1, dep2], () => {
    // do stuff
});

But let's see what happens 🙂

@crisward
Copy link

FWIW I currently make stuff update by passing in parameter into a reactive function call eg

<script>
let c = 0, a = 0, b = 0

function update(){
  c = a+b
}

S: update(a,b)
</sctipt>

Perhaps effect could take parameters on the things to watch, defaulting to all

$effect((a,b) => {
    c = a + b
});

That's got to be better that the 'untrack' example here https://svelte-5-preview.vercel.app/docs/functions#untrack

Also I think the $effect name is a very "computer sciencie", I'd personally call it watch, because that's what it feels like its doing. I think the below code says what it does.

$watch((a,b) => {
    c = a + b
});

@Evertt
Copy link

Evertt commented Sep 25, 2023

@crisward I personally love the syntax you came up with, but I'm sure there's gonna be a lot of complaints that "that's not how javascript actually / normally works".

@brunnerh
Copy link
Member

brunnerh commented Sep 25, 2023

It could cause issues/confusion when intentionally using recursive effects that assign one of the dependencies.
It would not make much sense to re-assign a function argument as that change would commonly be gone after the function scope.

A side note on the names: $effect keeps the association with side effects which is lost with $watch.
This rune is coupled to the component life cycle and will only execute in the browser; "watch" to me sounds more general than it actually is.

I am in favor of having explicit tracking, maybe even as the recommended/intended default; as of now the potential for errors with $effect seems huge, given that it can trigger on "invisible" signals if any functions are called that contain them (related issue).

@Not-Jayden
Copy link
Contributor

Not-Jayden commented Oct 2, 2023

I'd opt for using a config object over an array if this were to be implemented.
e.g.

 $effect(() => {
    timesChanged++;
  }, {
  	track: [num],
  });

More explicit, and makes it more future-proof to other config that might potentially be wanted. e.g. there could be a desire for an untrack option as well to explicitly opt out of specific dependencies that might be nested in the effect, or you could define a pre boolean instead of needing $effect.pre

@Blackwidow-sudo
Copy link

Perhaps effect could take parameters on the things to watch, defaulting to all

$effect((a,b) => {
    c = a + b
});

But that would mean that we cant pass callbacks like it was intended before.

function recalc() {
  c = a + b
}

$effect(recalc)

Im not really in favor of untrack but i dont see a better solution. Passing an array of things to track is also very annoying, isn't that what react does?

@aradalvand
Copy link

aradalvand commented Nov 6, 2023

The API shapes I'd propose:

$effect.on(() => {
    // do stuff...
}, dep1, dep2, dep3);

Or

$watch([dep1, dep2, dep3], () => {
    // do stuff
});

I actually prefer $watch, primarily because we'll also need to have a .pre variation for this, which would make it look a little weird: $effect, $effect.pre, $effect.on, $effect.on.pre.

With $watch, on the other hand, we'll have: $effect, $effect.pre <=> $watch, $watch.pre. Nice and symmetrical.

Plus, the word watch makes a lot of sense for this, I feel.

@opensas
Copy link
Contributor

opensas commented Feb 15, 2024

I think something like this would be pretty useful. Usually when I needed to manually control which variables triggered reactivity it was easier for me to think about which variables to track, and not which ones to untrack.
To avoid adding new runes (and new stuff to teach and learn) I guess it would be easier to let $effect track every referenced variable by default, but allowing to explicitly specify dependencies, either with an extra deps array or with a config object.

@eddiemcconkie
Copy link

I'd be in favor of a $watch rune like @aradalvand proposed, as I ran into a use case for it today.
I have several inputs on a page (id, name, description, location) and some input groups (tags and contents) which are kept in an array of states. I have the following code to mark the save state as "dirty" when any of the inputs change:

let { id, name, description, location, tags, contents } = $state(data.container);
let saveState = $state<'saved' | 'saving' | 'dirty'>('saved');
$effect(() => {
	[id, name, description, location, ...tags, ...contents];
	untrack(() => {
		saveState = 'dirty';
	});
});

I'm using the array to reference all the dependencies. The tags and contents both need to be spread so that the effect triggers when the individual array items change. Then I need to wrap saveState = 'dirty' in untrack so that it doesn't cause an infinite loop. With a $watch rune, it could look like this:

$watch([id, name, description, location, ...tags, ...contents], () => {
	saveState = 'dirty';
})

I think it makes it more obvious why the array is being used, and it simplifies the callback function since untrack is no longer needed.

@opensas
Copy link
Contributor

opensas commented Feb 23, 2024

@eddiemcconkie that's a great example of a good opportunity to explicitly define dependencies

we could also add an options parameter like this

$effect(() => saveState = 'dirty', { track: [id, name, description, location, ...tags, ...contents] })

which I think has the advantage of reflecting that it really does the same as a regular $effect with explicit dependecies

@eddiemcconkie
Copy link

@opensas would that still track dependencies based on what's referenced in the callback? I think the difference with the $watch proposal is that it wouldn't automatically track dependencies

@opensas
Copy link
Contributor

opensas commented Feb 24, 2024

@opensas would that still track dependencies based on what's referenced in the callback? I think the difference with the $watch proposal is that it wouldn't automatically track dependencies

no, in case dependencies are explicitly passed, references in callback should be ignored. you are telling the compiler "let me handle dependencies".

@Evertt
Copy link

Evertt commented Feb 24, 2024

Just to add my 2 cents. I find an extra $watch rune less confusing in this case. I'd rather have two runes that serve a similar purpose, but use two very different ways of achieving that purpose. Than to have a single rune whose behavior you can drastically change when you add a second parameter to it.

@dummdidumm
Copy link
Member

I want to reiterate that it's really easy to create this yourself: #9248 (comment)
Given that it's so easy I'm not convinced this warrants a new (sub) rune

@FeldrinH
Copy link

FeldrinH commented Mar 7, 2024

I want to reiterate that it's really easy to create this yourself: #9248 (comment) Given that it's so easy I'm not convinced this warrants a new (sub) rune

On the other hand, if every project starts to define similar helpers, possibly with different argument orders and names, then this will harm the readability of code for anyone not familiar with the helpers used in that specific project and will add mental overhead to switching between projects. I feel that having an effect with a fixed set of dependencies tracked is common enough that it really should be part of the core library.

@opensas
Copy link
Contributor

opensas commented Mar 7, 2024

I want to reiterate that it's really easy to create this yourself: #9248 (comment) Given that it's so easy I'm not convinced this warrants a new (sub) rune

On the other hand, if every project starts to define similar helpers, possibly with different argument orders and names, then this will harm the readability of code for anyone not familiar with the helpers used in that specific project and will add mental overhead to switching between projects. I feel that having an effect with a fixed set of dependencies tracked is common enough that it really should be part of the core library.

I wholeheartedly agree with @FeldrinH, it's not just whether it's easy or difficult to achieve such outcome, but providing an idiomatic and standard way to perform something that is common enough that it's worth having it included in the official API, instead of expecting everyone to develop it's own very-similar-but-not-quite-identical solution.

Anyway, providing in the docs the example provided by @dummdidumm would really encourage every one to adopt the same solution.

@Gin-Quin
Copy link

Gin-Quin commented Mar 14, 2024

Actually I would do it like this in Svelte 4:

<script>
  let num = 0;
  let timesChanged = 0;

  $: incrementTimesChanged(num);

  function incrementTimesChanged() {
    timesChanged++
  }
</script>

Which you can do the same way in Svelte 5 (EDIT: you can't, see comment below):

<script>
  let num = $state(0);
  let timesChanged = $state(0);

  $effect(() => incrementTimesChanged(num));

  function incrementTimesChanged() {
     timesChanged++
  }
</script>

But I agree that a watch function / rune would be awesome and add much readability for this use case:

<script>
  let num = $state(0);
  let timesChanged = $state(0);

  $watch([num], incrementTimesChanged);

  function incrementTimesChanged() {
     timesChanged++
  }
</script>

I'm actually a fan of the $watch rune idea. In opposition to $effect, it would allow fine-grained reactivity. $effect can be messy because you have to read all the code inside the function to guess wha have triggered it.

Imagine a junior developer wrote 50 lines instead a $effect rune and it's your job to debug it...

@ottomated
Copy link
Contributor Author

@Gin-Quin

Which you can do the same way in Svelte 5:

Actually, the point is you can't do it the same way. Try it—that code will cause an infinite loop as it detects timesChanged being accessed even inside the function. That's why I still think that a separate rune for this is useful, for clarity even if it can be implemented in userspace.

@Gin-Quin
Copy link

Gin-Quin commented Mar 15, 2024

Wow, you're right. I really need to change my vision of reactivity with Svelte 5.

This also means $effect is even more dangerous than I thought, and would re-run in many undesired cases.

"Side-effects" are bad design in programming, and $effect is promoting "reactivity side-effects" by its design.

The previous example is actually a very good minimal example of a reactivity side-effect:

<script>
  let num = $state(0);
  let otherNum = $state(0);

  $effect(() => logWithSideEffect(num));

  function logWithSideEffect(value) {
    console.log(value)
    console.log(otherNum) // side-effect, which triggers unwanted "side-effect reactivity"
  }
</script>

When reading the line with $effect, you expect it to run when num changes, but not when otherNum changes. This means you have to look at the code of every function called to check what triggers the whole effect function.

There is another caveat with the $effect rune. Let's say you want to log a value reactively, but only in some conditions:

<script>
  let num = $state(0);

  $effect(() => {
    if (shouldLog()) {  
      console.log({ num }) 
    } 
  });  

  function shouldLog() {
    return Math.random() > 0.3 // this is just an example
  }
</script>

You would expect the $effect callback to run every time numchanges, right?

But that's not what will happen. It will log at first, until it will randomly (once chance out of three) stop logging forever.

A $watch rune would solve it:

<script>
  let num = $state(0);

  $watch(num, () => {
    if (shouldLog()) {  
      console.log({ num }) 
    } 
  });  

  function shouldLog() {
    return Math.random() > 0.3
  }
</script>

@Evertt
Copy link

Evertt commented Mar 15, 2024

@Gin-Quin well I agree with almost everything you're saying. Especially the lack of transparency of when an $effect() would re-run could be a serious problem I think.

This also means $effect is even more dangerous than I thought, and would re-run in many undesired cases.

However, this is simply a bug that I believe could be fixed. I believe some other frameworks / libraries have fixed this already. Jotai for example, say this in their docs about their atomEffect:

  • Resistent To Infinite Loops: atomEffect does not rerun when it changes a value with set that it is watching.

But I do fully support adding the feature to run an effect based on an explicitly defined list of dependencies. Since an $effect() that watches an explicitly defined list of dependencies would need a significantly different implementation, I also think it makes sense to make it a different rune with a different name. And $watch makes a hell of a lot of sense to me.

@Blackwidow-sudo
Copy link

Blackwidow-sudo commented Mar 15, 2024

There is another caveat with the $effect rune. Let's say you want to log a value reactively, but only in some conditions:

<script>
  let num = $state(0);

  $effect(() => {
    if (shouldLog()) {  
      console.log({ num }) 
    } 
  });  

  function shouldLog() {
    return Math.random() > 0.3 // this is just an example
  }
</script>

You would expect the $effect callback to run every time numchanges, right?

But that's not what will happen. It will log at first, until it will randomly (once chance out of three) stop logging forever.

Oh wow, this is actually very weird behaviour:

let count = $state(0)

// Whole $effect callback wont run anymore
$effect(() => {
  console.log('Effect runs')

  if (false) {
    console.log(count)
  }
})

@brunnerh
Copy link
Member

This is neither a bug, nor how $effect is supposed to be used.

$effect tracks only the dependencies it actually needs; they can change with every execution of the effect.
Having randomness like this in an effect is also not very representative of actual use cases.

@Blackwidow-sudo
Copy link

This is neither a bug, nor how $effect is supposed to be used.

$effect tracks only the dependencies it actually needs; they can change with every execution of the effect. Having randomness like this in an effect is also not very representative of actual use cases.

I understand, but it is not intuitive imo.
When i write:

let num = $state(0)

$effect(() => {
  console.log('Hello')

  if (false) {
    console.log(num)
  }
})

I would assume that the first console.log would always get executed when num changes.

@jsudelko
Copy link

For what it's worth, SolidJS 2.0 will likely separate dependency tracking from effect execution to support their work on async reactivity.

https://youtu.be/_EkUCF4HzWg?t=17538s

@AlexRMU
Copy link

AlexRMU commented Sep 23, 2024

See also #12908

@Ocean-OS
Copy link
Contributor

Perhaps, since there's an untrack function, there could be a track function as well that could be used like this:

let a = $state(0);
let b = $state(0);
$effect(()=>{
track(b); //or track(()=>b) for consistency with untrack
console.log(a);
})

It would only be for readability, and would do pretty much nothing under the hood.

@DanielSidhion
Copy link

Just found this issue because I was looking for a good solution to exactly the case of marking something as unsaved/dirty that was already mentioned.

In my case, I have an object with a deep structure. It's holding the entire state that the user is working with so it can be saved to a file through JSON.stringify. Each "piece" of this object is being passed as a prop to different components, and rather than sprinkling a bunch of onchange custom events throughout the components, it really would be easier to "watch" for that state to change. See the end of my comment for my current code.

(using spreads like the other solution isn't straightforward. There are lots of sub-properties, with some of them being arrays that are passed to multiple components in {#each} blocks)

The fact that I can get fine-grained reactivity with $state for this object works super well when passing pieces of it as a prop, and the only thing missing seems to be a way to deeply "watch" the object, which is exactly what $inspect does, but only in dev mode.

To answer the question about "should it be deeply reactive", a proposal would be to define $watch.deep as well and reuse $inspect mechanism. (It's basically $inspect(<expression>).with(callback).)

Based on what @Gin-Quin said, I think instead of a $watch.deep, it would be better to let $inspect work anywhere, not just in dev mode. I would be fine with this being some setting in svelte.config.js that I have to set as a proxy for acknowledging its potential performance impact, but I think a good disclaimer on $inspect's docs would suffice.

I don't know the specifics of how $inspect works, but I imagine that having a single $inspect in all of my app that tracks something that may change only on user input wouldn't be heavy enough to negatively affect the user experience.

For reference, this is my current code:

$inspect(stateToSave).with((type) => {
	if (type === 'init') {
		return;
	}
	hasStateChanged = true;
});

@paoloricciuti
Copy link
Member

Inspect is deleted in production code so please don't use it for such things.

@Celestialme
Copy link

You can implement this in user land:

function explicitEffect(fn, depsFn) {
  $effect(() => {
    depsFn();
    untrack(fn);
  });
}

// usage
explicitEffect(
  () => {
     // do stuff 
  },
  () => [dep1, dep2, dep3]
);

this is very comfortable to use, if only it was rune like $effect.by() or $effect.on(). it makes reading effects very easy since seeing directly what its reacting to rather reading whole function body (especially when its complex). Also avoids infinite loops.

@Celestialme
Copy link

My understanding from these conversations:

  • $effect is a footgun.
  • Making the gun safer will only make people abuse the gun more for things that should not be solved with a gun.
  • The solution is to force people to learn how to use the gun by allowing them to shoot themselves in the foot.

So, the footgun is a feature?

Yes, we should make it safer to use because it’s a powerful tool. Saying we shouldn’t make it safer is like saying we shouldn’t use any tool that could hurt us—like avoiding going outside because we might get hit by a bus!

@Ocean-OS
Copy link
Contributor

Ocean-OS commented Nov 10, 2024

$effect is very useful, but people use it in such ways that they could just as easily use a $derived (and have better results). $effect is a side effect, which can make it dangerous, but that doesn't mean it should be removed. It just should have more options on how to use it in a safer way.
$effect is primarily meant as an escape hatch, when there's no better way to accomplish something.

@Celestialme
Copy link

$effect is very useful, but people use it in such ways that they could just as easily use a $derived (and have better results). $effect is a side effect, which can make it dangerous, but that doesn't mean it should be removed. It just should have more options on how to use it in a safer way. $effect is primarily meant as an escape hatch, when there's no better way to accomplish something.

derived values are not mutable which limits usage very much. This makes me to go back to effect but I think it should be better practice to control what its reacting to if body is somewhat complex.

@IcyFoxe
Copy link

IcyFoxe commented Feb 14, 2025

Based on the (slightly tweaked) example in the very first response by @dummdidumm, I exported the explicitEffect function as an utility, and find using it with this syntax very easy and readable:

explicitEffect(
  () => [dep1, dep2, ...],
  () => {
    // do stuff
  },
);

Does it have any potential performance issues? And should I be worried that it will stop working after some Svelte update?

@caboe
Copy link

caboe commented Mar 13, 2025

For me, it works best just to implement a small watch function:

const watch = (...deps: unknown[]): void => {
    deps.forEach(dep => {
        if (typeof dep === "object" && dep !== null) {
            // Deep access for objects/arrays
            JSON.stringify(dep) // Lightweight way to touch all properties
        } else {
            dep?.toString()
        }
    })
}

And then just call it with all dependencies in the effect rune:

	$effect(() => {
		watch(obj.arr, foo, baz)
		
		sideEffectFn()
	});

@therealsujitk
Copy link

therealsujitk commented Mar 14, 2025

For me, it works best just to implement a small watch function:

const watch = (...deps: unknown[]): void => {
    deps.forEach(dep => {
        if (typeof dep === "object" && dep !== null) {
            // Deep access for objects/arrays
            JSON.stringify(dep) // Lightweight way to touch all properties
        } else {
            dep?.toString()
        }
    })
}

And then just call it with all dependencies in the effect rune:

	$effect(() => {
		watch(obj.arr, foo, baz)
		
		sideEffectFn()
	});

@caboe in this example, wouldn't $effect() rerun if anything inside sideEffectFn() changes too?

@Blackwidow-sudo
Copy link

Blackwidow-sudo commented Mar 22, 2025

For me, it works best just to implement a small watch function:
const watch = (...deps: unknown[]): void => {
deps.forEach(dep => {
if (typeof dep === "object" && dep !== null) {
// Deep access for objects/arrays
JSON.stringify(dep) // Lightweight way to touch all properties
} else {
dep?.toString()
}
})
}

And then just call it with all dependencies in the effect rune:
$effect(() => {
watch(obj.arr, foo, baz)

	sideEffectFn()
});

@caboe in this example, wouldn't $effect() rerun if anything inside sideEffectFn() changes too?

Damn i wasn't even aware of that. Just tested it with an example. Idk...im dont really like the fact that effects do this deep tracking. This means i would have to recursively follow all the function calls in an effect to make sure it doesn't trigger without intension.

@Evertt
Copy link

Evertt commented Mar 22, 2025

@Blackwidow-sudo that sounds like you are always still thinking in terms of procedural programming. All modern frontend frameworks work best with the mental model of declarative programming. And in fact, once you're able to think in that way, your code suddenly becomes way cleaner than it could ever be in the procedural style.

So when you think of an $effect() declaratively, instead of thinking about what effect should happen when your state changes in a certain way, you should think of what effect should happen when your state is a certain way. That should make it clear that every piece of state referenced within that $effect() (including deeply referenced ones), whether that's in an if-statement or in an assignment, it should be tracked.

Of course there can be exceptions to this rule, but this rule should work for like 95% of use-cases.

@Blackwidow-sudo
Copy link

@Evertt Youre right, i was not really thinking in a declarative way. But still, i think this could be made clearer in the docs.

@caboe
Copy link

caboe commented Mar 25, 2025

For me, it works best just to implement a small watch function:
const watch = (...deps: unknown[]): void => {
deps.forEach(dep => {
if (typeof dep === "object" && dep !== null) {
// Deep access for objects/arrays
JSON.stringify(dep) // Lightweight way to touch all properties
} else {
dep?.toString()
}
})
}

And then just call it with all dependencies in the effect rune:
$effect(() => {
watch(obj.arr, foo, baz)

	sideEffectFn()
});

@caboe in this example, wouldn't $effect() rerun if anything inside sideEffectFn() changes too?

Yes, it would. This could lead to infinitive loops. But this could also happen under other condition like two effects.
In that case, you should check if you have to rerun the effect and otherwise do an early return.

@IcyFoxe
Copy link

IcyFoxe commented Apr 16, 2025

Here's type defined utility function for Vue-like effect, with specific dependency tracking and previous values:
(Edit: use the one in the comment below)

import { untrack } from "svelte";

export const effectBy = <T extends readonly unknown[]>(depsFn: () => [...T], fn: (prevDepValues: [...T] | null) => void) => {
  let prevDepValues: [...T] | null = null;

  $effect(() => {
    const curDepValues = depsFn();
    untrack(() => fn(prevDepValues));
    prevDepValues = curDepValues;
  });
};

Usage:

effectBy (
  () => [dep1, dep2, ...],
  (prevDepValues) => {
    // do stuff
  },
);

Hope someone will find it useful. 😉

@caboe
Copy link

caboe commented Apr 17, 2025

@IcyFoxe Unfortunately, this will not work with, if you do not make an assignment:

let num = $state([])
const addNum = ()=> num.push(num.length)

effectBy (
  () => [num],
  (prevDepValues) => {
    console.log(num.length, prevDepValues)
  },
);

Will not log out something, if you call addNum...
https://svelte.dev/playground/58e91fc3d5e14fd3b08b864629c45e1c?version=5.22.4

@IcyFoxe
Copy link

IcyFoxe commented Apr 17, 2025

Ah, you're absolutely right @caboe
Proxies are at fault... So it only works with plain values. I tried using JSON.stringify & JSON.parse within the effectBy function, and it works, but that's far from a good solution.

Anyone has a better idea?

Edit:
What do we think about using $state.snapshot instead?:

import { untrack } from "svelte";

export const effectBy = <T extends readonly unknown[]>(deps: () => [...T], fn: (prevDepValues: $state.Snapshot<[...T]> | undefined[]) => void) => {
  let prevDepValues: $state.Snapshot<[...T]> | undefined[] = [];

  $effect(() => {
    untrack(() => fn(prevDepValues));
    prevDepValues = $state.snapshot(deps());
  });
};

It certainly works now, even with arrays, but again, can someone from the Svelte team inform us, whether this is a good approach performance-wise? Or does it have any unwanted side effects?

@alexdilley
Copy link

alexdilley commented May 22, 2025

A common — and arguably less esoteric — way of implementing your example in Svelte 3/4 is this pattern:

let num = 0
let times_changed = 0

$: inc(num)

function inc () {
  times_changed++
}

Could a translation of this just be:

let num = $state(0)
let times_changed = $derived.by(() => pre_inc(num))

function pre_inc () {
  return times_changed + 1
}

Or have I missed the point?

EDIT: confirmed this only works where the side-effect is an evaluation that doesn't interrogate the value eventually assigned to. Sorry!

@metroite
Copy link

metroite commented Jun 2, 2025

I think the point of this issue has come across. Is there an rfc proposing this?

@Rich-Harris
Copy link
Member

No because this is an anti-feature #9248 (comment)

@IcyFoxe
Copy link

IcyFoxe commented Jun 2, 2025

I'm usually on your side Rich, but not with this one. I come from Vue, and explicitly specifying tracking dependencies has so many advantages over Svelte automatically deducing them.

  • you immediately know what is triggering the side effect, instead of looking for it in multiple lines of code
  • using $untrack to remove unwanted dependencies is much more cumbersome than just stating the wanted dependencies
  • ability to obtain the previous value after the dependency has changed is super useful

Here one example how I use my effectBy and effectByPre utility functions:

  // Store video player timestamp
  effectByPre(
    () => [file],
    ([fileOld]) => {
      // File has updated, but the video player is still the previous one
      if (fileOld && previewVideoElement) {
        savedVideoTimes[fileOld.id] = previewVideoElement.currentTime;
      }
    },
  );
  // Restore video player timestamp
  effectBy(
    () => [file],
    () => {
      if (file && previewVideoElement) {
        previewVideoElement.currentTime = savedVideoTimes[file.id] || 0;
      }
    },
  );

Of course as with everything, maybe there's a better way to go about it, but to me this is already very readable and does what I want it to do. :)

I like Svelte, because I could write Vue-like watch utility that behaves exactly as I want, and as long as it will work, I'm fine with Svelte not having a dedicated $watch rune.


Anyways, I'll paste again my Typescript decorated utility functions, so that others can use, and maybe even improve them.

import { untrack } from "svelte";

export const effectBy = <T extends readonly unknown[]>(deps: () => [...T], fn: (prevDepValues: $state.Snapshot<[...T]> | undefined[]) => void) => {
  let prevDepValues: $state.Snapshot<[...T]> | undefined[] = [];

  $effect(() => {
    untrack(() => fn(prevDepValues));
    prevDepValues = $state.snapshot(deps());
  });
};

export const effectByPre = <T extends readonly unknown[]>(deps: () => [...T], fn: (prevDepValues: $state.Snapshot<[...T]> | undefined[]) => void) => {
  let prevDepValues: $state.Snapshot<[...T]> | undefined[] = [];

  $effect.pre(() => {
    untrack(() => fn(prevDepValues));
    prevDepValues = $state.snapshot(deps());
  });
};

@Rich-Harris
Copy link
Member

After discussion with other maintainers, we're closing this as wontfix. The reasons have been articulated at length above (in particular see #9248 (comment) and #9248 (comment)) but to briefly recap:

Take canvas rendering for example — I'm drawing a red circle because color is 'red', not because it changed to 'red' ... This might seem like a subtle and unimportant distinction but it's absolutely essential to understanding how to wield effects responsibly.

A good rule of thumb is that if you're setting state inside an effect, you're probably doing it wrong.

Again: effects are not about responding to state changing in a certain way, they are about to responding to state being a certain way. The place to respond to changes is in event handlers and subscription callbacks and function bindings. If you internalise this idea, I promise you will be able to write code that is more robust, more readable and more concise.

But if you think you truly have a use for explicit dependencies — and I cannot emphasise enough that you probably don't! — then as #9248 (comment) shows you can trivially do this yourself:

You can implement this in user land:

function explicitEffect(fn, depsFn) {
  $effect(() => {
    depsFn();
    untrack(fn);
  });
}

// usage
explicitEffect(
  () => {
     // do stuff 
  },
  () => [dep1, dep2, dep3]
);

If you need to react to changes to properties of those dependencies — and again, this is a bad idea and you really shouldn't do this! — then you can use $state.snapshot to read objects deeply:

explicitEffect(
  () => {
     // do stuff 
  },
- () => [dep1, dep2, dep3]
+ () => [$state.snapshot(dep1), $state.snapshot(dep2), $state.snapshot(dep3)]
);

"But React has explicit dependencies"

Yes — so that React knows about them, not so you can control them. A lesson that React users learn the hard way over and over again is that if you don't specify the right dependencies, effects become buggy. That's why React ships with a lint rule that yells at you if you specify dependencies incorrectly (where 'incorrectly' means 'the explicit dependencies don't perfectly match the implicit ones').

"But my effect is running too often"

Let's say you have a contrived scenario like this, where both data and timestamp are state, but you only need to synchronize data with an external system — the timestamp is purely for logging:

$effect(() => {
  send(data, timestamp);
});

You might be tempted to make the dependency on data explicit...

watch(
  () => send(data, timestamp),
  () => [data]
);

...but this is flawed: it won't fire if only data.foo changes, but if you change it to $state.snapshot(data) it will fire on changes to data.property_we_dont_actually_care_about, which (suppose) send wasn't including in the payload.

It also creates a maintenance burden and space for bugs. Imagine one day it becomes this, and it 'works' because data and stuff are both written to at the same time.

send({ ...data, ...stuff }, timestamp);

Unless you were careful enough to add stuff to the explicit dependencies, then if you one day change stuff separately from data, it will stop working correctly.

Instead, you should untrack timestamp:

$effect(() => {
  send(data, untrack(() => timestamp));
});

(Realistically, in most cases timestamp just shouldn't have been state in the first case, but untrack exists as an escape hatch for the times when it needs to be.)

"But my effect isn't running often enough"

Another scenario is this, where condition is not state:

$effect(() => {
  if (condition) {
    do_something_with(count);
  }
});

This effect is just buggy: condition needs to become state for it to work correctly.

"But I can't work out why my effect is re-running"

If you want explicit dependencies because you're just not sure why an effect is re-running, then first of all your effect is honestly probably too complex and you should try to simplify it. Avoid putting complex logic in effects!

But in the cases where the complexity is unavoidable, it's far better to diagnose the issue than to try and avoid it. The $inspect.trace() rune exists for that purpose (and will be getting an upgrade soon in #16060).

"But Vue has watch"

Vue has a... lot of things.

"But I need to synchronize two pieces of state when one changes"

You're looking for $derived. As of 5.25, you can overwrite a derived value, which you should do instead of setting state in an effect.

"But I need to know the previous value"

This is an example of responding to state changing, which should happen in an event handler rather than an effect. Instead of doing this...

<script lang="ts">
  import { watch } from './utils.svelte.js';
  
  let temperature = $state(50);
  let change = $state<'warmer' | 'colder' | null>(null);

  watch(
    (temperature, previous) => {
      if (previous === undefined) return;
      change = temperature > previous ? 'warmer' : 'colder';
    },
    () => temperature
  );
</script>

<input type="range" bind:value={temperature} />

<p>{temperature} {#if change}({change}){/if}</p>

...do this — it's less code, it's clearer, I don't need to guard against previous being undefined, it eliminates any timing-related bugs (i.e. temperature and change are updated at the same moment, it's not possible to read temperature before change is updated), and it's a lot more efficient (instead of tracking dependencies and dirtying the effect tree and re-running stuff we're just... updating state):

<script lang="ts">
  let temperature = $state(50);
  let change = $state<'warmer' | 'colder' | null>(null);
</script>

<input
  type="range"
  bind:value={() => temperature, (t) => {
    change = t > temperature ? 'warmer' : 'colder';
    temperature = t;
  }}
/>

<p>{temperature} {#if change}({change}){/if}</p>

The same goes for storing the currentTime of a media element — just do it in an event handler.


I could write Vue-like watch utility that behaves exactly as I want, and as long as it will work, I'm fine with Svelte not having a dedicated $watch rune

This is the heart of the issue. We're not preventing you from doing whatever it is you need to do. We're just not providing a convenience API for explicit dependencies, because explicit dependencies are a footgun, and the alternatives — even if it takes a moment to develop the right habits of thought to see them — are invariably more efficient and more robust.

@Rich-Harris Rich-Harris closed this as not planned Won't fix, can't repro, duplicate, stale Jun 2, 2025
@sveltejs sveltejs locked as resolved and limited conversation to collaborators Jun 2, 2025
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Projects
None yet
Development

No branches or pull requests