svelte icon indicating copy to clipboard operation
svelte copied to clipboard

Reactive binding for Context API values

Open endigo9740 opened this issue 1 year ago • 3 comments

Describe the problem

Please note there is a workaround to this issue currently, but requires a lot of extra boilerplate in which you create and pass a writable store down the component tree. I feel this goes against the nature of Svelte's simplicity-first approach.

Per real-world use cases, this actually affects a number of components created for my UI component library. Especially those that are utilized within forms. I've drawn sample code from my Radio Group component to illustrate the problem: https://skeleton.brainandbonesllc.com/components/radio-groups

First, Users are required to import writable, create a new writable store (per component instance set), and pass the store to the component via a prop:

<script lang="ts">
import { writable } from "svelte/store";
const storeJustify = writable(0); // this is require PER instance set
let formData: any = {
    justify: $storeJustify
};
</script>

<RadioGroup selected={storeJustify}>
    <RadioItem value={0}>Left</RadioItem>
    <RadioItem value={1}>Center</RadioItem>
    <RadioItem value={2}>Right</RadioItem>
</RadioGroup>

Then, the parent component (RadioGroup) must again import the writable (for type handling), import setContext, then accept the prop value, as well as provide this prop value to context using the setContext method:

<script lang="ts">
    import type { Writable } from "svelte/store";
    import { setContext } from "svelte";

    export let selected: Writable<any>;
    setContext('selected', selected);
    
    // ...
</script>

<nav><slot /></nav>

Finally, for the child components (RadioItem) we once again must import the writable type, import getContext, grab the store value with getContext, as well as take in a new value prop which defines the child element's unique value. I can then use these to values to compare and set the appropriate "highlighted" CSS styling.

<script lang="ts">
    import type { Writable } from "svelte/store";
    import { getContext } from "svelte";

    export let value: any;
    export let selected: Writable<any> = getContext('selected');
    
    $: selected = value === $selected ? `(set highlight CSS styles)` : `(set base CSS styles)`;
</script>

<li>
    <label>
        <input class="hidden" type="radio" {value} bind:group={$selected} />
        <slot />
    </label>
</li>

Describe the proposed solution

My suggestion would be to either make Context values reactive by default, or allow for some kind of per-instance configuration to enable this as an optional feature. Though I would understand there may be some performance or technical limitations preventing this.

If we imagine a scenario where this was enabled by default, then the resulting code would be much simpler overall for developers implementing my library's components.

We need only define a value that represents the currently selected value. Then pass a reference to the parent:

<script lang="ts">
let formData: any = {
    justify: 0
};
</script>

<RadioGroup selected={formData.justify}>
    <RadioItem value={0}>Left</RadioItem>
    <RadioItem value={1}>Center</RadioItem>
    <RadioItem value={2}>Right</RadioItem>
</RadioGroup>

We drop the extra writable type import here.

Note my proposed setContextReactive syntax.

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

    export let selected: any;
    setContextReactive('selected', selected);
</script>

<!-- ... -->

We drop the extra writable type import here too.

Note my proposed getContextReactive syntax.

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

    export let value: any;
    export let selected: any = getContextReactive('selected');
    
    // ...
</script>

<!-- ... -->

This proposed update provides an exponential improvement if you have a page with a lot of custom form component, since you no longer have to maintain an extra set of store definitions per component set.

Alternatives considered

Alternatively, it might be great if there was some way to use a bind-like syntax and tap directly into Context and is reactive by default. Something like this:

<script lang="ts">
let formData: any = {
    justify: 0
};
</script>

<RadioGroup context:selected={formData.justify}>
    <RadioItem value={0}>A</RadioItem>
    <RadioItem value={1}>B</RadioItem>
    <RadioItem value={2}>C</RadioItem>
</RadioGroup>

You could then skip the extra boilerplate for setContext within the parent component definition. Then use something like the proposed getContextBinding syntax to retrieve the value within the child.

Importance

would make my life easier

endigo9740 avatar Aug 08 '22 18:08 endigo9740

The core problem here is that we want the functions exported from 'svelte' to be real, regular functions. There's nothing that setContextReactive or getContextReactive could be defined as that would cause their arguments or their return values to be reactive. We don't want the Svelte compiler to treat these as magic imports and to emit any different code as it would for any other call to any other function imported from any other module. And the way to have reactive values, when you don't have access to the compiler magic and when the reactivity could come from anywhere else in the code, is to use stores.

Conduitry avatar Aug 08 '22 18:08 Conduitry

Understood. I just wanted to bring this issue to the forefront so it could be discussed. I completely understand if my proposals are not possible or more trouble than they are worth, but wanted to illustrate what I was aiming for.

The current solution (passing stores down the tree) definitely works, but is a bit verbose and adds complexity. Complexity seems to go against what Svelte is all about. It's a pain point us and our users have run into, so I'm 100% open to any other proposed ideas or solutions to make this better.

We very much plan to continue working with things as they are now in the meantime!

Thanks!

endigo9740 avatar Aug 08 '22 18:08 endigo9740

The core problem here is that we want the functions exported from 'svelte' to be real, regular functions. There's nothing that setContextReactive or getContextReactive could be defined as that would cause their arguments or their return values to be reactive. We don't want the Svelte compiler to treat these as magic imports and to emit any different code as it would for any other call to any other function imported from any other module. And the way to have reactive values, when you don't have access to the compiler magic and when the reactivity could come from anywhere else in the code, is to use stores.

I completely agree with @endigo9740 that there should probably be some way to both create a store and limit its subscribers to child components at the same time. This feels like a very appropriate thing to tackle in Svelte v4.0 @Rich-Harris (although, it doesn't require any breaking changes). It seems like we just lack the built-in expressiveness to wrap both a store declaration and set a context in one concise statement.

While it's better than bind: directives ad nauseum, a parent component that sets lots of contexts for numerous child components can still get really bloated by this boilerplate:

A bad, bloated way to set lots of stores in contexts CleanShot 2022-09-02 at 22 34 20@2x

Here's a more concise version of that same pattern: CleanShot 2022-09-02 at 21 50 48@2x

It's ok, but still not very new-user-friendly. I certainly stayed away from the context API when I first started learning Svelte because stores just felt better to write -- a credit to the ease of using the $ syntax. I think we can still improve the ergonomics and default "key safety" of using reactive contexts as described in the tutorial, all in one fell swoop:

CleanShot 2022-09-03 at 01 45 05@2x

I love that this is so easily composable with Svelte! I just wish it was more accessible to newcomers, i.e. built-in to the framework! Now, when we use it, even the typing (both keystrokes, and the type-safety) get slightly easier! CleanShot 2022-09-03 at 02 01 21@2x I avoided re-importing Writable and writable() in this scenario entirely and still get the full type-safety/intellisense benefits! CleanShot 2022-09-03 at 02 03 46@2x CleanShot 2022-09-03 at 02 09 27@2x

In this highly opinionated framework that aims to make web-dev fun, easy, and accessible, we should continue to encourage using the best-practices --using stores and contexts instead of prop-drilling, and unique keys for reliable access-- not just in the tutorial, but in the language itself! Maybe it's worth putting in an option to escape using Symbols (and the required string keys), but this seems like the sensible default use-case.

jrmoynihan avatar Sep 03 '22 01:09 jrmoynihan