Skip to content

Allow to return state in *.svelte.js/ts #9965

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
adiguba opened this issue Dec 20, 2023 · 13 comments
Closed

Allow to return state in *.svelte.js/ts #9965

adiguba opened this issue Dec 20, 2023 · 13 comments

Comments

@adiguba
Copy link
Contributor

adiguba commented Dec 20, 2023

Describe the problem

We can use $state() in *.svelte.js/ts files, but it's not possible to return the state himself, as the following code will return the value :

export function func() {
	const value = $state(0); // compiled to $.source(0);
	// ... 
	return value; // compiled to $.get(value);
}

In order to have a reactive state, we need to encapsulate the state in an objet, like this for example :

export function func() {
	let value = $state(0);
	// ... 
	return {
		get: () => value,
		set: (v) => value = v
	};
}

Or this :

export function func() {
	let value = $state({value:0});
	// ... 
	return value; // value is a proxified object
}

But all these solution imply that we handle this on the template, with something like {varName.get()} or {varName.value} instead of using directly {value} like classic $state declared inside <script>

Describe the proposed solution

Allow *.svelte.js/ts file to declare function that return a state.

We can use a name convention and a compiler check for this.
For example function's name prefixed by $ will return a state, so :

export function $func() {
	const value = $state(0); // compiled to $.source(0);
	// ... 
	return value;     // compiled as is
}

The compiler should produce an error if the return of the value is not a state.

This will allow to do this in *.svelte files :

<script>
    import { $func } from './file.svelte.ts';

   let name = $func(); // 'name' is handled as a state, as if it had been created with $state()
</script>

{name} will be compiled as $.get(text)

This will allow to create all sort of states, like what you can do with stores, without changing the usage in the template.
For example for a state backed on localstorage :

export function localstorage(name, initialValue) {
	let value = $state( window.localStorage.getItem(name) ?? initialValue );
	$effect( () => {
		if (value == null) {
			window.localStorage.removeItem(name);
		} else {
			window.localStorage.setItem(name, value);
		}
	});
	return value;
}

Alternatives considered

Using current behavior, and changing the template based on how we create the state.

Importance

nice to have

@mimbrown
Copy link
Contributor

I had proposed something similar to this before. I think instead of trying make a convention with any function starting with $, it is a bit cleaner (but more verbose) to introduce something like a $box/$unbox rune pair. So that $box wraps up a reactive variable in an opaque Box type which can only be unwrapped by $unbox. Then your examples would look like this:

export function func() {
	const value = $state(0); // compiled to $.source(0);
	// ... 
	return $box(value);     // compiled to just `return value`;
}
<script>
    import { func } from './file.svelte.ts';

   let name = $unbox(func()); // 'name' is handled as a state, as if it had been created with $state()
</script>

More verbose, but actually works in more places than just function boundaries (works for global exported state too). Plus it goes nicely with the runes vibe... "to transport your runified state, you must first place it in a magical box" etc.

@adiguba
Copy link
Contributor Author

adiguba commented Jan 17, 2024

@mimbrown Seem a pretty good solution for me.

@Thiagolino8
Copy link

Thiagolino8 commented Jan 17, 2024

@mimbrown, I think your solution is more prone to errors as it depends on the developer remembering to wrap and unwrap the signal, doing it in the correct locations and as exposes the signal to the developer, something the team wants to avoid
Furthermore, it would become obsolete if #9968 passes, where it will be possible to do

export function func() {
  const value = $state(0);
  // ... 
  return () => value;
}
<script>
  import { func } from './file.svelte.ts';

  let name = $derived.with(func());
</script>

@adiguba's solution is better because in addition to enforcing a convention, all the work continues to be done by the compiler, protecting the signal implementation

@TGlide
Copy link
Member

TGlide commented Jan 17, 2024

Furthermore, it would become obsolete if #9968 passes, where it will be possible to do

You can't assign to derived state though

@mimbrown
Copy link
Contributor

mimbrown commented Jan 17, 2024

it depends on the developer remembering to wrap and unwrap the signal

Except that's exactly what you're doing, just in a less "blest" way.

exposes the signal to the developer, something the team wants to avoid

The box could be implemented in a way that doesn't expose the signal. Actually it could be implemented to do more or less what you wrote, just more efficiently and with write capabilities.

The idea is, normally we want to create state in such a way where we control the modification of the state. But sometimes, we want to create some state and completely delegate the future modifying of it to the outside world, but have some custom logic run in an effect whenever it changes. Like if we want to sync it with local storage, or query parameters, or some third-party service, or write some custom debugger (though $inspect is already pretty awesome it looks like).

@Thiagolino8
Copy link

Except that's exactly what you're doing, just in a less "blest" way.

In your proposal, the developer needs to wrap and unwrap the signal, in @adiguba's proposal you mark the function that returns a signal and the compiler takes care of the wrapping and unwrapping

@mimbrown
Copy link
Contributor

In your proposal, the developer needs to wrap and unwrap the signal, in @adiguba's proposal you mark the function that returns a signal and the compiler takes care of the wrapping and unwrapping

That's fair. I meant to respond specifically to the example you had added.

Here's my concerns with the original proposal:

  • I think the $ would start to feel way overloaded (runes, stores, now any "custom" rune?).
  • It means the information about what the function returns now has to come from both the return site and the function name, which is harder to reason about.
  • It brings up a lot of weird edge cases. For example what would this mean?
export function $func() {
  if (someCondition) {
    return 5;
  }
  let myState = $state(0);
  return myState;
}
  • Or what happens if I import $func and call it in a non-svelte file?

@adiguba
Copy link
Contributor Author

adiguba commented Jan 18, 2024

  • I think the $ would start to feel way overloaded (runes, stores, now any "custom" rune?).

Yes, a special code for special treatment...
I think that this can replace and deprecate the store interface.

  • It means the information about what the function returns now has to come from both the return site and the function name, which is harder to reason about.

Like async function or function* generator...

  • It brings up a lot of weird edge cases. For example what would this mean?

I's invalid !
Svelte is a compiler, and it can produce a compile error in this case.

  • Or what happens if I import $func and call it in a non-svelte file?

Yes it's a problem as it's return an unknown object, with unspecified behavior :/

One solution to this can be to return a specific type defined like this :

type BoxedState<T> = {
	get(): T;
	set(newValue: T);
}

So this :

export function $func() {
	const value = $state(0);
	// ... 
	return value; 
}

Will be compiled to something like this :

export function $func() {
	const value = $.source(0);
	// ... 
	return { get: ()=>$.get(value), set: (v) => $.set(value, v) };
}

So the returned value can be used can be used in "classic" javascript's code, and enhanced in *.svelte files...

@7nik
Copy link
Contributor

7nik commented Jan 18, 2024

I think that this can replace and deprecate the store interface.

No, stores allow bringing reactivity to/from third-party libraries while letting them still be mostly framework-agnostic.

Like async function or function* generator...

Nope, their return types are Promise<...>, Iterator<...> and AsyncIterator<...> or smth. While func and $func both return from the POV of TS, e.g. just number.

  • Or what happens if I import $func and call it in a non-svelte file?

There are lots of ways of leaking such a function and thus leaking the raw signal. Preventing all of them will be a huge PITA for the team and an annoying limitation for the users.

Boxing the signal means you can feed the svelte code a boxed signal with custom setter/getter logic, which seems the team don't like.


BTW, isn't #9951 about the same problem - passing somewhere the state but not its value?

I even proposed there an idea that seemed to meet the team's requirements unless I overlooked something.

@adiguba
Copy link
Contributor Author

adiguba commented Jan 18, 2024

Stores allow to bring reactivity to/from third-party libraries, and I think we should have something similar with states.
Now with the fine-grained reactivity, stores seems more archaic to me.

=> So how I can pass a state to/from third-party libraries ?

And yes, event if at first i was thinking that this issue and #9951 were two separate problems, I think they can have a common solution.

Otherwise, your idea is similar to the $box/$unbox idea from mimbrown.

@adiguba
Copy link
Contributor Author

adiguba commented Jan 18, 2024

For exemple if I want a state to be backed on a sessionStorage, currently with fine-grained reactivity I can use something like this :

// simplified implementation, ignoring parse/format :
export function sessionStorageState(key, initialValue) {
	const state = $state({ value : sessionStorage.getItem(key) ?? initialValue} )
	$effect( () => {
		sessionStorage.getItem(key, state.value);
	});
	return state;
}

But to use it I have to update my code in order to use an object instead of a simple state.
Exemple :

<script>
+	import { sessionStorageState} from './storage.svelte.js';
-	let count = $state(0);
+	let count = sessionStorageState('key', 0);

	function increment() {
-		count += 1;
+		count.value += 1;
	}
</script>

<button onclick={increment}>
-	clicks: {count}
+	clicks: {count.value}
</button>

I think it would be better to only have to change the state declaration, and use it directly.

@flo-at
Copy link

flo-at commented Jan 22, 2024

I tried to export a $state from the module context of a .svelte file and found out that it doesn't work as expected after importing it from another file. Probably for the same reason as discussed here, it's not recognized as a $state. So right now the two options I have are:

  1. use a store instead of a state
  2. encapsulate the state in an object (as in the issue description above)

Or am I missing something?

@Rich-Harris
Copy link
Member

Closing for the reasons given in #9237 (comment)

@Rich-Harris Rich-Harris closed this as not planned Won't fix, can't repro, duplicate, stale Apr 4, 2024
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

8 participants
@flo-at @Rich-Harris @adiguba @mimbrown @TGlide @7nik @Thiagolino8 and others