Skip to content
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

Svelte 5: can't link reactivity from $props() #14536

Closed
noslouch opened this issue Oct 28, 2024 · 14 comments
Closed

Svelte 5: can't link reactivity from $props() #14536

noslouch opened this issue Oct 28, 2024 · 14 comments

Comments

@noslouch
Copy link

noslouch commented Oct 28, 2024

Describe the bug

This is most apparent when implementing pagination, but when trying to use deeply nested reactivity with page data objects in a svelte kit route (e.g. $state(data.foo)), it breaks the connection to $props().

Accessing directly with dot notation stays in sync with $props(), but does not have reactive state (as expected). The only way to stay in sync with $props() and get reactive state is to use component context to create local reactive state.

Reproduction

Here's a stack blitz with sveltekit repro. I can only get the expected reactivity by creating $state() in a component based on local primitive values.

https://stackblitz.com/edit/svelte-rune-page-data-bug?file=src%2Froutes%2F%2Bpage.server.ts,src%2Froutes%2F%2Bpage.svelte,src%2Flib%2Fbullet.svelte

And here's a svelte repro that shows similar behavior, but since the parent App.svelte component can create $state() and pass it into the component, the issue is not as apparent.

https://svelte.dev/playground/6fe00097a3104b11b9922df28b668598?version=5.1.3

Logs

No response

System Info

System:
    OS: macOS 14.7
    CPU: (10) arm64 Apple M1 Pro
    Memory: 83.23 MB / 32.00 GB
    Shell: 5.9 - /bin/zsh
  Binaries:
    Node: 22.1.0 - ~/.nvm/versions/node/v22.1.0/bin/node
    npm: 10.8.3 - ~/.nvm/versions/node/v22.1.0/bin/npm
    bun: 1.1.15 - ~/.bun/bin/bun
  Browsers:
    Brave Browser: 129.1.70.126
    Chrome: 130.0.6723.70
    Chrome Canary: 132.0.6803.0
    Safari: 18.0.1
  npmPackages:
    @sveltejs/adapter-node: ^5.2.8 => 5.2.8 
    @sveltejs/kit: ^2.0.0 => 2.7.2 
    @sveltejs/vite-plugin-svelte: ^4.0.0 => 4.0.0 
    svelte: ^5.0.0 => 5.1.0 
    vite: ^5.0.3 => 5.4.10

Severity

serious, but I can work around it

Additional Information

No response

@noslouch
Copy link
Author

One workaround (intended pattern?) we were discussing over in discord was using $effect() to maintain the $props() connection and nested reactivity.

// +page.svelte
let { data } = $props();

// deeply reactive state
let cards = $state(data .cards);

// updated when the load function re-runs
$effect(() => {
  cards = data.cards;
});

Which makes a certain kind of sense, based on this graf:

$effect ... is useful when you need to synchronize an external system (whether that’s a library, or a <canvas> element, or something across a network) with state inside your Svelte app.

We're syncing local app state with new data from the network, so maybe this isn't a bug, but the intended behavior.

If so, I think this kind of example should be discussed in the docs, because it feels like a gotcha. I'd be happy to write up something.

@ahkelly
Copy link

ahkelly commented Oct 29, 2024

IMHO this is a huge regression and breaks every page on our large svelte 4 application as they all use (what we were trained to do) the data directly in our bindings with full reactivity by default.

export let data


bind:value={data.db_query_result.column_name}

this now has to have onerous boiler plate additional refactor everywhere as follows

let { data } = $props();
let data_db_query_detail = $state(data.db_query_result);

bind:value={data_db_query_detail.column_name}

The migrate script just slaps a $bindable() on the data prop which does nothing in regards to reactivity

let {data = $bindable() } = $props();

@danieldiekmeier
Copy link

I'm having the same problem and I'm also unsure how to best handle this. A lot of pages in my SvelteKit app broke because I used something like this to unpack the data prop:

<script>
  export let data
  $: ({ order } = data)
</script>

<input bind:value={order.email} />

I want to be able to

  1. update the state locally
  2. receive prop updates. This is incredibly important when I navigate and SvelteKit reuses the same component instance.

I tried a few different things. This one does not work because I can't update the state locally:

const { data } = $props()
const { order } = $derived(data)

This one does not work because the state does not receive prop updates:

const { data } = $props()
const { order } = $state(data)

This one does not work because the Svelte compiler gives an error "$state(...) can only be used as a variable declaration initializer or a class field - svelte(state_invalid_placement)":

const { data } = $props()
const { order } = $derived($state(data))

But this one does work:

function withWrites<T>(initialState: T): T {
  const state = $state(initialState)
  return state
}

const { data } = $props()
const { order } = $derived(withWrites(data))

This feels doubly weird, because I can't use state directly … but I can wrap it in a function and then it's fine? While this does work, this almost feels like a coincidence. But I think I'll still go with this for now, as it at least does what I want, is pretty clean and I don't need the additional $effect boilerplate.

@benmccann
Copy link
Member

You can do it with an $effect, but it feels like the worst of all available options to me.

We have this issue anywhere you want to set the value of some $state both from a prop and some other source. There are two possible general solutions. The one that exists today is to use a callback prop. There was also some discussion of introducing something along the lines of $state.link (some discussion in #12943).

The case of data is a bit of a special one because because it's the SvelteKit framework invoking the component and so users don't have control over it. We could introduce an onDataUpdate callback prop, but this doesn't feel like the nicest API to, which I think speaks to the benefit of something like $state.link. Also, it would make migrations and updating more difficult. The second solution for SvelteKit's data would be if we merge sveltejs/kit#11809 to bind data. This is fairly backwards compatible. I don't think we should generally encourage two-way data, but this would be a pretty harmless instance of it since there's only one page at a time accessing data and we don't have to worry about multiple stomping on each other. The thing that makes me hesitate is that it feels like we're teaching the wrong pattern where we need to rely on heavy usage of $bindable and if a $state.link or similar existed then there's a good argument that it might be the cleaner solution.

@benmccann benmccann changed the title Svelte 5: deeply nested reactivity breaks link with $props() Svelte 5: can't link reactivity from $props() Nov 18, 2024
@benmccann benmccann transferred this issue from sveltejs/kit Dec 3, 2024
@kwangure
Copy link
Contributor

kwangure commented Dec 4, 2024

<script>
    let { data } = $props();

    // Is editable, but resets when the `data.cards` signal is updated
    let cards = stateLink(() => data.cards);

    function stateLink(getValue) {
		let wasLocal = false;
		let localState = $state(undefined);
	
		// Choose `localState` as the latest value if the mutation was local
		// Otherwise, choose the linked `getValue()` because the change was a reaction
		const linkedDerived = $derived.by(() => {
			const linkedValue = getValue();
			localState; // watch
			if (wasLocal) {
				wasLocal = false;
				return localState;
			}
			return linkedValue;
		});
	
		return {
			get current() {
				return linkedDerived;
			},
			set current(v) {
				wasLocal = true;
				localState = v;
			},
		};
	}
</script>

See https://svelte.dev/playground/23189440a69f4b0ebccdc0e08964ec19

@ryanbmarx
Copy link

Interested in this solution. The Svelte4 pattern of page.data being reactive makes a lot of sense. Would like to see that pattern preserved in Svelte5.

@sacrosanctic
Copy link
Contributor

Interested in this solution. The Svelte4 pattern of page.data being reactive makes a lot of sense. Would like to see that pattern preserved in Svelte5.

solutions currently exist in sveltejs/kit#12999 and sveltejs/kit#13000

@DevDuki
Copy link

DevDuki commented Mar 16, 2025

I just stumbled upon this issue too and I am surprised that this (#15107 (comment)) seems to be the actual right way to do it?
Is this really intended? If so, I'm sorry to say it like this, but that's ugly af...

This feels so unlike svelte tbh and completely kills the DX, which is one of svelte's main selling points and why I love using svelteKit so far. I just started working on my first project with svelteKit, so I'm not that experienced with svelteKit, which is why it took me longer than I want to admit to find the issue to why my message in const { message } = data was not being updated. I was looking everywhere (page.ts, page.server.ts, layout.ts, etc.) until I actually realised it has something to do with the data returned by the server not being reactive. It was really frustrating to look for an answer because the doc lacks this kind of example and I wonder why, since I believe it's quite common to want to have certain properties in the data object to be reactive.

Also for a beginner it's already difficult enough to figure out when to use $state, store, context, $derived, $effect and now I casually should have figured out on my own that I needed a "simple" $state in a $derived.by? Totally unintuitive imo.

@paoloricciuti
Copy link
Member

We plan to add it to the docs soon, I also think we should probably abstract this concept in a $state.link rune, we are just trying to figure out if it's worth it considering is doable in userland

@jhwz
Copy link
Contributor

jhwz commented Mar 16, 2025

considering is doable in userland

Possible with something like

export class Linked<T> {
	value: T = $state() as T;
	constructor(get: () => T) {
		this.value = get();
		$effect(() => {
			this.value = get();
		});
	}
}

but it's a bit painful needing to access the nested .value prop everywhere, e.g.

let reactive = new Linked(() => someProp);
console.log(reactive.value); // would be nice if this was just 'console.log(reactive)'

As far as I know there's no way around this in userland?

@paoloricciuti
Copy link
Member

considering is doable in userland

Possible with something like

export class Linked {
value: T = $state() as T;
constructor(get: () => T) {
this.value = get();
$effect(() => {
this.value = get();
});
}
}
but it's a bit painful needing to access the nested .value prop everywhere, e.g.

let reactive = new Linked(() => someProp);
console.log(reactive.value)
As far as I know there's no way around this in userland?

See the linked comment...the much better way is creating state in derived.by

@jhwz
Copy link
Contributor

jhwz commented Mar 16, 2025

Sure, I'm not too bothered about the internals of what a userland version $state.link are (I assume you're referring to #15107 (comment)). The issue is you need to access an extraneous field value or current which just adds unnecessary noise to our templates.

@paoloricciuti
Copy link
Member

Sure, I'm not too bothered about the internals of what a userland version $state.link are (I assume you're referring to #15107 (comment)). The issue is you need to access an extraneous field value or current which just adds unnecessary noise to our templates.

Yeah if we do add $state.link it should work without the .value obviously

@benmccann
Copy link
Member

Addressed in #15570

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

10 participants