sveltejs / svelte

web development for the rest of us
https://svelte.dev
MIT License
79.75k stars 4.23k forks source link

Svelte 5: Unable to monitor state/derived without running the block #14091

Open KieranP opened 4 days ago

KieranP commented 4 days ago

Describe the bug

On page load, we pass the initial data into the component. Because of this, we don't need to fetch data again when the component is mounted, but if the query params change, we need to trigger a section of code to refetch results.

In Svelte 4, we had something like this (trimmed and simplified but it should be clear): ($applicationContextStore.params is just a store that initialized with params from the current URL)

<script lang="ts">
  export let users: User[]

  let params = $applicationContextStore.params

  $: fetchData({
    url: '/users',
    params, // <- updates to this will trigger this call
    onSuccess: (newUsers: User[]) => {
      users = newUsers
    })
  })
</script>

<a href="#" on:click={() => {
  params = { ...params, sort: 'asc' }
}}>Sort Asc</a>

<a href="#" on:click={() => {
  params = { ...params, sort: 'desc' }
}}>Sort Desc</a>

<table>....</table>

This worked well. Users were passed in when the component was rendered for the first time. Then if the sorting links were clicked, the params were updated, triggering the fetchData block.

With Svelte 5, there does not appear to be any way to monitor a block of code for state changes without it also first running.

<script lang="ts">
  interface Props {
    users: User[]
  }

  let { users }: Props = $props()

  let params = $state($applicationContextStore.params)

  $effect(() => {
    fetchData({
      url: '/users',
      params,
      onSuccess: (newUsers: User[]) => {
        users = newUsers
      })
    })
  })
</script>

<a href="#" onclick={() => {
  params = { ...params, sort: 'asc' }
}}>Sort Asc</a>

<a href="#" onclick={() => {
  params = { ...params, sort: 'desc' }
}}>Sort Desc</a>

<table>....</table>

The above doesn't work as intended. $derived, $derived.by, $effect, $effect.pre, and onMount all run the code when the component is mounted, which is not what we want, because we are already passing in the initial data. This ends up doubling requests which returns exactly the same data.

What we need is a $track function or something, that doesn't run when component is mounted, but does run when any state vairables in it are run.

On a different note, the migrator is converting it to use legacy/run, which behind the scenes looks like $effect.pre, so the migrator is recommending a change that breaks functionality.

Any ideas/advice?

Reproduction

See above

Logs

No response

System Info

System:
    OS: macOS 15.1
    CPU: (12) arm64 Apple M3 Pro
    Memory: 91.22 MB / 18.00 GB
    Shell: 5.9 - /bin/zsh
  Binaries:
    Node: 20.15.1 - ~/.asdf/installs/nodejs/20.15.1/bin/node
    Yarn: 4.3.1 - ~/.asdf/installs/nodejs/20.15.1/bin/yarn
    npm: 10.8.2 - ~/.asdf/plugins/nodejs/shims/npm
    bun: 1.1.0 - ~/.bun/bin/bun
  Browsers:
    Chrome: 130.0.6723.92
    Safari: 18.1
  npmPackages:
    svelte: ^5.1.9 => 5.1.9

Severity

blocking an upgrade

adiguba commented 4 days ago

Hello,

You could just use a boolean to ignore the first call.

Something like this:

let params = $state($applicationContextStore.params)

let first_run = true;
  $effect(() => {
    if (first_run) {
        params; // just to trigger on next change
        first_run = false;
        return;
    }
    fetchData({
      url: '/users',
      params,
      onSuccess: (newUsers: User[]) => {
        users = newUsers
      })
    })
  });
Leonidaz commented 4 days ago

Another option.

Not sure about your exact use case, but it seems that it's better to avoid using effects unless there is no other way. You can just create a click handler that takes sort as a parameter, e.g. Click Handler Example

Also, I would use a <button> instead of the <a> anchor tags as this is a user action vs a navigation. it's better for accessibility. You can just create an unstyled button with no borders, backgrounds, etc. to make it look like a link.

KieranP commented 4 days ago

@adiguba I had considered that, but it just doesn't feel very Svelte-like. It would be really great if Svelte had a $watch/$track/$monitor rune for this purpose, where it doesn't run when component is mounted, but does when any $state is referenced within is updated. e.g,.

<script lang="ts">
  interface Props {
    users: User[]
  }

  let { users }: Props = $props()

  let params = $state($applicationContextStore.params)

  $watch(() => {
    fetchData({
      url: '/users',
      params,
      onSuccess: (newUsers: User[]) => {
        users = newUsers
      })
    })
  })
</script>

@Leonidaz The example is extremely trimmed down and simplified to explain the core issue. In our actual app, we have a table of data, with each column being sortable, both asc & desc, we have a pagination component, and we have a filters sidebar with various search and filtering options. Any time any of them change, it updates a centralized params store, which in turn triggers the fetchData call through reactivity. It would be cumbersome to replace that with many click handlers.

adiguba commented 4 days ago

It would be really great if Svelte had a $watch/$track/$monitor rune for this purpose

With signal, you need to execute the code to detect the reactive value, so they must be declared in some way. But you can write something similar like this :

export function watch(get_states: () => any[], fn: () => (void | (() => void))) {
    let first_run = true;
    $effect(() => {
        get_states();
        if (first_run) {
            first_run = false;
        } else {
            return fn();
        }
    });
}

Usage :

watch(
    // States to watch on startup :
    () => params,
    // Effect :
    () => console.log("watch() : fetchData(" + JSON.stringify(params) + ")")
);

Example : https://svelte.dev/playground/untitled?version=5.1.9#H4sIAAAAAAAACqVSTW-cMBD9KyMrEqCu4M6yK0VKLz20h1VPdRU5ZLxxyw7IHtpEyP-9NrBkP9JLgixhz7x5zzN-gyB1QFGK78SGG3wUK6FNg06UPwbBL13MxUCIz8jbrsvdH2w4xh6Uw7fidUuMxIFGVK62pmNoFO03UrCTYitJsjl0rWUY4K_i-gk8aNseIMmL8TxT5b9cspYE0CBDp6w6ONjAjWPFmA7gAkMJCbWEic8Ca-Ady9O4k1wUsItQB9zOOi1BqLbcd1BOoDSDzXYmXy11n7XGms8xQzxA-EJ7rm0wb9p9KsWkmEEJGsPuTrEKUfgEX3bfvuaOraG90S_pJJGFhBRZXKEvn43t3eAoly461ypHyHt1JFXF9BRx_FQ99MxhGC3Vjal_b4YT6WXQA-R5Pk_mOGzl6gS8JO-3u_iAt66uionsY8SPeMF8h5fUncXtVFXC8HbXvioiavQCBScyPrMo2fboV_9x9IXhzm19nTzxNj6PHtY91WxCz5MT9sj3o0NDb5qyyTbRwNpYx_e2pzCBeKN1TFw9ffTbK0WaraeQ0ZAuBNkRKPmUVKvG4Yz3gOHwirPIvaVwoYXRx99oQH8-qJ_-H0YgcH4YBAAA

Otherwise, I think that @Leonidaz suggestion is way better if you got the full control on the param state. And it may even be simplier, as you can replace all your event like () => {params = { ...params, sort: 'asc' }} with a simple function call : () => updateParams({sort: 'asc' })

Example : https://svelte.dev/playground/untitled?version=5.1.9#H4sIAAAAAAAACpWRQWvDMAyF_4owgyaspPcsCRR62mEblJ2WMVxXac1cOcTKumL83-ckZaOjl-KL9fz0-cn2guQBRS5eiTUb3Iq5aLRBJ_I3L_jUDmeDEPWzc9m2mftCw4O2kQ6v6coSI3HEiMKpTrcMRtKurAW7WlQ1ARhkaGUnDw5KuHMsGRMPznacw4ws4SykNcXFTU-KtSXo2210vYxNCeHxY-pPwQ-2mn9xHrIsm6r5sP3zQniYvDGhswYzY3dJLRpktV9JlkkKR817qAXcw-P6-Slz3Gna6eaUnG9LR0IYshWLabhqLDY9c0xpSRmtPksfWWV1Gdqf55NOzSCkoVrHGpZOFYup-2bSFi9QK_zPajuspuQ5-OsThWIxuMbXpvh7jN8scu56DO_hBwnR1osjAgAA

Leonidaz commented 4 days ago

@adiguba both are awesome suggestions! 👍 The watch wrapper is really nice!

@KieranP I think either of these or combined together (one function to update) should work. The way signals work with effect, I don't think there is a better way other than setting a first run boolean. A wrapper for watch seems to do the trick to avoid declaring these first run vars everywhere.

I modified the @adiguba's watch example to just update the parameters that changed instead of destructuring a whole new params each time.

This way you can avoid changes unless something actually changed. So, in this example, if the same sort is applied, the fetch won't rerun. Playground: run less changes

But at the same time you can watch the whole params object by destructuring it in the watch. Or individual params properties can be watched.

7nik commented 4 days ago

@KieranP your Svelte 4 example seems to be oversimplified and missing something important - the $: blocks also run during the component initialization. Also, unlike $:, $effect doesn't run on the server.

trueadm commented 4 days ago

I really don't understand the Svelte 4 example, as that too runs on component init just like Svelte 5. As per the suggestions above, there are plenty of ways to get the desired behaviour you want without us needing to expand the API space to add something that probably isn't needed anyway.

I think the better approach here, and one we've been recommending in the docs is to do this work in the event handler where possible, to avoid effects entirely. Effects are designed to be used for side-effects outside of your app, but in this case, you're doing something based off an action rather than a side-effect.