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

A completely different approach to defining components #1826

Closed
Rich-Harris opened this issue Oct 30, 2018 · 25 comments
Closed

A completely different approach to defining components #1826

Rich-Harris opened this issue Oct 30, 2018 · 25 comments

Comments

@Rich-Harris
Copy link
Member

Ok, brain dump time.

We often get questions in the chatroom about why you can't use methods in templates, and that sort of thing. And React people often look at code like this...

<Nested foo={bar}/>

<script>
  import Nested from './Nested.html';

  export default {
    components: { Nested },

    data: () => ({
      bar: 1
    })
  };
</script>

...and ask 'why do you need to register the component? We don't have that problem because our apps are Just JavaScript™️'. And while there are good technical reasons for both of those things, in the context of the current design, the truth is they have a point.

I've occasionally wondered if there's a way to have the best of both worlds. It boils down to the template being able to access variables that are in scope in the <script>:

<script>
  import Nested from './Nested.html';
  const bar = 1;
</script>

<Nested foo={bar}/>

The issue, of course, is that bar is no longer reactive — there's no way to say 'this value has changed, please update the child component'.


Last week, the React team introduced hooks. If you haven't watched Sophie and Dan's talk, I do recommend it.

I've been slightly ambivalent about hooks — for the most part, they solve problems that are specific to React. But they do make me wonder if there's a way to solve the worst ergonomic drawbacks of Svelte's approach.


Here's a version of the example above using the useState hook to make bar reactive:

<script>
  import { useState } from 'svelte/hooks';
  import Nested from './Nested.html';

  const [bar, setBar] = useState(1);
</script>

<Nested foo={bar}/>
<button on:click="setBar(bar + 1)">increment</button>

I had a go at rewriting a few more examples in this style to get a sense of what it would entail. I've been fairly pleasantly surprised; in many cases no changes are necessary (because the components only contain markup and possibly styles), and in the cases where changes are necessary, the code gets cleaner and more concise.

What I haven't done is figure out exactly what this would mean for the compiler. Svelte's whole deal is that it avoids doing as much work as possible, and there are two main components to that — avoiding touching the DOM, and not giving the user a place to accidentally do unnecessary and expensive computation. This means a) knowing which values could have changed, and b) not having a big ol' render function. This approach clearly makes that harder. Impossible? Not sure.

Things I'm thinking about, in no particular order:

  • setFoo(...); setBar(...) instead of set({ foo: 1, bar: 2 }) means you really need to batch the work up, which sort of forces us into an async paradigm. Maybe that's ok? Not sure
  • There'd need to be a way to access the component's props inside the <script> — at least in useComputed (React has useMemo for a similar purpose) and the equivalent of useEffect
  • We've been encountering some tricky bugs recently around the order of oncreate, ondestroy etc, particularly when there are bindings or immediate set(...) calls. This feels like it could be a way to side-step those entirely
  • Need to consider the runtime complexity this would add
  • Not sure how this could work with standalone components. Hooks work by maintaining a central registry, which doesn't immediately make sense for standalone things
  • There's no obvious way to do inter-component bindings. Maybe export { thingMyParentWants }?
  • Need a way to fire events. They don't really exist in React-land but they're handy!
  • What would this mean for SSR?
  • There's certainly no need to slavishly follow the exact design of hooks — some folks are thinking about interop between different frameworks, but if it turns out not to make sense then there's no need to treat it as a constraint

This issue might alarm some of you; rest assured I'm not going to immediately rewrite Svelte just because Sunil goaded me on Twitter, and this is just the very beginning of this process of exploration. It may go nowhere. If we do end up using some of these ideas, we could probably leverage svelte-upgrade.

Key point to note: even if we do end up creating slightly more work for Svelte than it currently has to do (by making it less clear which values could have changed, etc) we'd still be in a better place than React, since a) we don't have the overhead of a virtual DOM, b) we have all the affordance of HTMLx, c) ...including <style>.

I think it's at the very least worth considering — keeping Svelte feeling modern, and in line with evolving developer tastes, is an important consideration alongside the mission of making it easy to write small, fast apps (or making it difficult to write large slow ones).

@Rich-Harris
Copy link
Member Author

Let me quickly vomit another half-formed thought onto the screen:

We're a compiler. That means we don't need to play by everyone else's rules. So we could do something like this...

<script>
  import Nested from './Nested.html';
  let bar = 1;
</script>

<Nested foo={bar}/>
<button on:click="bar += 1">increment</button>

That bar += 1 could be augmented thusly:

button.addEventListener('click', () => {
  bar += 1;
  scheduleRerender(['bar']);
});

Every time a variable is assigned to, the compiler could trigger a re-render with knowledge of what changed. This would also work inside the <script> tag:

<p>the time is {time.toISOString()}</p>

<script>
  import { oncreate } from 'svelte/lifecycle';

  let time = Date.now();

  oncreate(() => {
    let interval = setInterval(() => {
      time = Date.now();
    }, 1000);

    return () => clearInterval(interval);
  });
</script>
  oncreate(() => {
    let interval = setInterval(() => {
      time = Date.now();
      scheduleRerender(['time']);
    }, 1000);

    return () => clearInterval(interval);
  });

This is composable — you just need to use a closure:

// React-style
const [time, setTime] = useState(new Date());
useCustomHook(setTime);

// Svelte-style
let time = new Date();
const setTime = () => time = new Date();
useCustomHook(setTime);

@PaulMaly
Copy link
Contributor

PaulMaly commented Oct 30, 2018

@Rich-Harris First example in the last comment is absolutely awesome!

Most important idea:

We're a compiler. That means we don't need to play by everyone else's rules.

@Rich-Harris
Copy link
Member Author

A couple of other things that occurred to me on the subway:

  • This will make it sooo much easier to add first-class TypeScript support to Svelte components. That's huge
  • I think we can construct things in such a way that the code inside the <script> tag only runs once, as it currently does, rather than on every re-render (which is what is implied by hooks). In that last example, where we import a lifecycle function (I have to get out of the habit of calling them 'lifecycle hooks', that word is now loaded), we don't need to rely on any funky order-dependence tricks. We might be able to completely bypass both the weirdness and the performance gotchas of hooks, and be in a better position, ergonomically speaking

God damn, I'm excited

@TehShrike
Copy link
Member

My initial reaction to this is: I have ideas for wacky alternate ways I'd like to interact with component state, but I'm waiting for the store contract to become formalized before I start getting crazy.

Could we enable that and play around with alternate state management in userland a while? It wouldn't give us the "the script tag is the scope", which is cool, but it might let us play around with a hooks-like setWhatever scheme, or whatever other interface people dream up.

@Rich-Harris
Copy link
Member Author

I do want that, yeah. Though I do also wonder if our ideas about state management would be different if we adopted an alternative approach to defining components. I think we should proceed, gingerly, on both fronts until we have some more clarity, but treating the store stuff as a more near-term tangible goal.

@Rich-Harris
Copy link
Member Author

A question from the chatroom, from @mrkishi:

did you have any ideas on syntax for receiving attributes?

In the markup, that's not a problem:

<!-- these are both (hopefully!) easy to understand -->
<div>{propThatWasPassedIn} / {localState}</p>

It gets trickier with computed properties, and with effects, to adopt React's terminology.

Those could both be handled with functions — for example (alpha footage, not representative of final product):

<script>
  import { compute, onupdate } from 'svelte/whatever';

  let filter = null;

  let filtered = compute(props => {
    return filter
      ? props.items.filter(filter)
      : props.items;
  });

  onupdate(props => {
    console.log(props.items, filtered);
  });
</script>

{#each filtered as item}
  <p>{item.description}</p>
{/each}

It becomes slightly clearer there that those functions will re-run when props changes, perhaps.

@TehShrike
Copy link
Member

If that computed contract/API is well-defined, the Functional Reactive crowd will be falling over themselves to bring their own favored implementations to use with Svelte.

@Rich-Harris
Copy link
Member Author

Something I noted in chat — it'd be nice (from both implementation and documentation perspectives) if the only magic was around assignments to variables and properties. So perhaps computed values would be better attacked in this fashion:

import { observe } from 'svelte/whatever';

let value = 1;
let someComputedThing;

observe(['value'], () => {
  someComputedThing = value * 2;
});

That way, setting value triggers a round of observers, which triggers the assignment to someComputedThing, which gets included with value in the list of changed properties that need to be considered during the subsequent render.

I don't love the string array argument, just throwing it out there for now.

@tivac
Copy link
Contributor

tivac commented Oct 30, 2018

I don't love the string array argument, just throwing it out there for now.

Since svelte doesn't have to be normal JS, could it be:

observe(value, () => {
  someComputedThing = value * 2;
});

instead? The compiler could turn that into an array of strings or whatever it needs to and users could get a super-easy-to-follow API.

@Rich-Harris
Copy link
Member Author

It's not impossible, and in fact I just took this screenshot of the React docs which talks about a similar idea:

screen shot 2018-10-30 at 5 30 13 pm

Having said that I think it's preferable to contain the magic as far as possible, not least because it results in more opportunities for composition (e.g. you could call observe from a helper that was shared between modules, if the compiler didn't need to muck about with it).

@PaulMaly
Copy link
Contributor

I also think about how could we represent the public interface of a single component. Right now, Svelte's output is just a JS constructor. But seems it's not adaptive to the new approach.

Perhaps, we can just allow a component's script to be freer what and how to export outside:

<script>
  let count = 0;

  export function getCount() {
     return count;
  }
  export function setCount(val) {
      count = val;
  }
</script>

<button on:click="--count">-</button>
<input bind:value="count">
<button on:click="++count">+</button>
import { getCount, setCount } from './Counter.html';

getCount(); // 0
setCount(1); // 1

@ansarizafar
Copy link

I really like this syntax

<script>
import { observe } from 'svelte/whatever';

let count = 1;
let double = count * 2;
 
observe(value, () => {
  someComputedThing = value * 2;
});
</script>

<div on:click = "count += 1">
{count} {double}
</div>

The syntax for computed value/property is so simple and elegant. I don't know why people are confused, Its very clear that double is dependent on count.

I don't love the string array argument, just throwing it out there for now.

We can have both string array argument and variable syntax for observe.

@Rich-Harris
Copy link
Member Author

Exporting functions that way is definitely a possibility. I was thinking, though, that maybe in this new world components wouldn't have methods, which are intrinsically tied to a concept of 'classes' which doesn't quite gel with the approach. (With my implementer's hat on, it's also hard to imagine what the compiler would transform that to — the scoping gets a little tricky.)

Someone (I think @mrkishi) made a really neat suggestion yesterday, which is that the export keyword could be used to indicate which values could be set from outside — i.e. 'props'. I really like the look of this:

<script>
  export let foo = 'default foo value';
  export let bar = 'default bar value';
</script>

<p>{foo} {bar}</p>

Under the hood it might compile to something like this:

function create_main_fragment(component, ctx) {
  // ...
}

const Component = createComponent((__update, __props) => {
  let foo = 'default foo value';
  let bar = 'default bar value';

  __props(({ changed, current }) => {
    foo = current.foo;
    bar = current.bar;
    __update(changed, { foo, bar });
  });
}, create_main_fragment);

Private state that was derived from props could be done in the same way we were talking about handling computed properties in Discord yesterday — as thunks:

<script>
  export let foo = 'default foo value';
  export let bar = 'default bar value';

  let baz = () => foo + bar;
</script>

<!-- baz() will never be rendered with the default values -->
<p>{foo} {bar} {baz()}</p>

(If necessary the compiler could memoize these thunks as appropriate, by statically analysing the dependency graph.)

@tivac
Copy link
Contributor

tivac commented Oct 31, 2018

Using exports as data props seems fine to me, I do like the explicitness of saying "these are my template vars".

I'm really not sold on computed coming from assignment though, or anything where a simple assignment is transformed into a function. I don't mind the compiler being smart and doing smart things but when it fundamentally changes behavior I start to get a little suspicious/spooked.

@Rich-Harris should baz also be exported in the example? It seems weird to export data props but have computed values implicitly available to the template.

@PaulMaly
Copy link
Contributor

PaulMaly commented Oct 31, 2018

@tivac Seems, only things what we want to be available outside of the component should be exported. All variables and functions defined in the script always available in the template from the beginning.

@Rich-Harris
Are we'll be able to export 'props' values already after declaration?

<script>
  let foo = 'default foo value';
  let bar = 'default bar value';
  ...
  export foo;
  export bar;
</script>

<p>{foo} {bar}</p>

@TehShrike
Copy link
Member

exported things being changeable from the outside would seem weird - with ES modules, those exported things aren't re-assignable.

@Rich-Harris
Copy link
Member Author

I'm really not sold on computed coming from assignment though

That's what the thunk idea avoids — no compiler magic involved there (except the dependency tracking part used to avoid unnecessary re-rendering).

should baz also be exported in the example?

No — the distinction is between exported public values and non-exported private values — foo, bar and baz are all available to the template, but only foo and bar are part of the component's contract with the outside world

Are we'll be able to export 'props' values already after declaration?

Yeah, no reason why not — though the syntax would be export { foo, bar }.

exported things being changeable from the outside would seem weird - with ES modules, those exported things aren't re-assignable.

This did occur to me. But we're already abusing export default as a way to make it unambiguous which object is the component definition, rather than to export the things on that definition — the actual default export is something completely different, and no-one really objects to it. I think this is the same thing — it's an abuse of syntax, but for a reason. As long as people have some idea of what the compiled code looks like (and I think we can do a better job of communicating that), I reckon it'd be accepted.

@Conduitry
Copy link
Member

Conduitry commented Oct 31, 2018

I mentioned this in chat but I want to have it here too:

I'm really not sure how user-defined, publicly-accessible methods fit in to all this. Would they just be const exports that happen to be functions? (This seems like it would clash with thunks-as-computeds.) Or maybe they could be old-fashioned function exports? Actually, that probably makes more sense, as arrow functions as methods won't really work, because of the this stuff.

edit: Okay, I missed that above it was suggested that methods be done away with, but I am really not comfortable with that.

edit again: There isn't really a this to worry about though anyway, maybe. As everything is just local variables. I dunno.

@tivac
Copy link
Contributor

tivac commented Oct 31, 2018

No — the distinction is between exported public values and non-exported private values — foo, bar and baz are all available to the template, but only foo and bar are part of the component's contract with the outside world

Just to make sure I'm following;

<script>
  // Any declared variable is accessible in the template function,
  // by default they are not able to be set from outside the
  // component's <script> block
  let foo = 'default foo value';
  var bar = 'default bar value';
  const faz = 'default faz value';

  // Exporting variables makes them settable from outside a component
  export { foo, bar };
  
  // Computed functions are denoted by... ?
  // The fact that they depend on a tracked variable?
  let baz = () => foo + bar;
</script>

<!-- baz() will never be rendered with the default values -->
<!--
    baz() is memoized somehow, maybe even rewritten so it's not a
    function invocation if the dependencies haven't changed?)
-->
<p>{foo} {bar} {baz()} {faz}</p>

@Conduitry
Copy link
Member

An idea from @mrkishi - Does it really matter whether we can distinguish computed-property-thunks from methods?

Maybe not?

@Rich-Harris
Copy link
Member Author

My thinking is that 'computed properties' no longer exist as a separate Svelte-specific concept — instead you Just Write JavaScript and the compiler wires up the annoying bits.

In other words, with...

const baz = () => foo + bar;

...foo and bar could be private state, they could be props, they could even be imports. So nothing 'denotes' computed functions, they just... are.

When I talk about memoization, what I really mean is that <p>{foo} {bar} {baz()}</p> could result in the following update code, without mucking around with the definition of baz at all:

function update(changed, ctx) {
  if (changed.foo) text1.data = ctx.foo;
  if (changed.bar) text2.data = ctx.bar;
  if (changed.foo || changed.bar) text3.data = ctx.baz();
}

The if statement 'is' the memoization. (It's not, of course — we're not memoizing anything, and nor should we since memoization isn't free. It's just to say that baz won't get called unless foo or bar changed since the last render. You could do your own memoization if that was appropriate, e.g. if baz was called multiple times in a single render.)

I agree that we probably do want some way to attach methods to a component for the sake of standalone widgets — not sure what that would be yet — I just don't think that's necessarily the thing to optimise for.

@tivac
Copy link
Contributor

tivac commented Oct 31, 2018

The if()-protected version of the re-render for the function is what I was getting at with

baz() is memoized somehow, maybe even rewritten so it's not a function invocation if the dependencies haven't changed?)

but didn't express clearly. Glad we're on the same page there!

@arxpoetica
Copy link
Member

exported things being changeable from the outside would seem weird - with ES modules, those exported things aren't re-assignable.

I mentioned this in the #future forum, but if we can make the JS aspect of this the least magical, I see a long play in this all with trying to motivate other frameworks to adopt ECMAx (not JSX) standard, i.e., something so similar to JS, but adding just enough value to standardize across frameworks, kind of like the play toward HTMLx. Not to derail the above, but it's a thought...

@ansarizafar
Copy link

Methods/actions and refs both have a place. If we decide to use expressions for computed values then we can use arrow functions for private/public methods/actions. The compiler should insert refs into the component scope. Here is my component setup.

App.html
<script>
import {oncreate, onupdate, ondelete} from 'svelte'

let count = 1;
// Computed
let double = count * 2;
{store, router} = context

oncreate(() => {
console.log("component created")
})

// Private method
let onClick = (event) => {
console.log(refs.name.value)
}

// Public method
let open = (page) => {
store.pageTile = page.title
router.go(page.url)
}

export { count, open}

</script>

<input ref:name bind:value=count>
<button on:click="onClick(event)">Submit</button>

main.js
import { Store } from 'svelte';
import router from 'somewhere'

const app = new App({
	target: document.querySelector('main'),
        props: {count : 3 },
	context: {
                   store: new Store({pageTitle: 'Home'}),
                   router: router
                       }
     });

@Rich-Harris
Copy link
Member Author

This now exists in RFC form so I'll close this issue:

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

7 participants