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

Async render generator #29

Closed
streamich opened this issue Mar 4, 2018 · 38 comments
Closed

Async render generator #29

streamich opened this issue Mar 4, 2018 · 38 comments

Comments

@streamich
Copy link

streamich commented Mar 4, 2018

This is not a proposal per se, just wanted to see your thoughts on async generator as a render function.

Stateless component:

async function * MyComp () {
  yield (<div>Loading...</div>);
  const users = await fetch('/users');
  return <UserList users={users} />;
}

Stateful component:

class MyComp extends Component {
  async * render () {
    yield (<div>Loading...</div>);
    const users = await fetch('/users');
    return <UserList users={users} />;
  }
}

What it solves?

  1. Async data loading.
  2. Async data loading on server side.

On server side it could render only once the iterator has been fully resolved, while on client it would render all intermediate steps of the iterator.

For example, on client it would render:

  1. <div>Loading...</div>
  2. <UserList users={users} />

On server it would wait until render method fully resolves and render the final result only:

  1. <UserList users={users} />
@j-f1
Copy link

j-f1 commented Mar 4, 2018

What would happen if you rerendered MyComp?

@streamich
Copy link
Author

streamich commented Mar 4, 2018

@j-f1 On client it would first render <div>Loading...</div>, then <UserList users={users} />. On server it would just render <UserList users={users} />.

@j-f1
Copy link

j-f1 commented Mar 4, 2018

@streamich Here’s the situation I was thinking about:

async function* MyComponent({ onClick, buttonLabel }) {
  yield (<div>Loading...</div>);
  const users = await fetch('/users');
  return <div>
    <UserList users={users} />
    <button onClick={onClick}>{buttonLabel}</button>
  </div>;
}

ReactDOM.render(<MyComponent buttonLabel="doThingOne" onClick={...} />, container)
ReactDOM.render(<MyComponent buttonLabel="doThingTwo" onClick={...} />, container)

Would /users be fetched twice, and would the loading indicator be displayed twice?

@streamich
Copy link
Author

streamich commented Mar 4, 2018

ReactDOM.render(<MyComponent buttonLabel="doThingOne" onClick={...} />, container)
ReactDOM.render(<MyComponent buttonLabel="doThingTwo" onClick={...} />, container)

Would render loading indicator and fetch users only once. But you could force it do it twice, if you wanted, using the key prop.

ReactDOM.render(<MyComponent key="1" buttonLabel="doThingOne" onClick={...} />, container)
ReactDOM.render(<MyComponent key="2" buttonLabel="doThingTwo" onClick={...} />, container)

However, I see a "stale prop problem" in stateless async components — by the time it renders the button the buttonLabel prop is already out of date.

This is not a problem with stateful components however, and for stateless components it could be solved in two ways.

Solution 1 - using this.props just like in stateful component:

async function* MyComponent() {
  yield (<div>Loading...</div>);
  const users = await fetch('/users');
  yield (
    <div>
      <UserList users={users} />
      <button onClick={this.props.onClick}>{this.props.buttonLabel}</button>
    </div>
  );
}

Solution 2 - feeding props to generator on every step:

async function* MyComponent(props) {
  props = yield (<div>Loading...</div>);
  const users = await fetch('/users');
  yield (
    <div>
      <UserList users={users} />
      <button onClick={props.onClick}>{props.buttonLabel}</button>
    </div>
  );
}

@j-f1
Copy link

j-f1 commented Mar 4, 2018

Would render loading indicator and fetch users only once. But you could force it do it twice, if you wanted, using the key prop.

Then how would the component be able to update itself when its props are changed?

@j-f1
Copy link

j-f1 commented Mar 4, 2018

What would happen if the second ReactDOM.render call happened after the users were loaded?

@streamich
Copy link
Author

streamich commented Mar 4, 2018

The first thing that comes to mind is that it could yield a closure:

async function * MyComponent() {
  yield (<div>Loading...</div>);
  const users = await fetch('/users');
  yield () =>
    <div>
      <UserList users={users} />
      <button onClick={this.props.onClick}>{this.props.buttonLabel}</button>
    </div>;
}

@streamich
Copy link
Author

streamich commented Mar 4, 2018

There could be a new keyword added to JSX which creates the closure automatically:

async function * MyComponent() {
  use (<div>Loading...</div>);
  const users = await fetch('/users');
  use (
    <div>
      <UserList users={users} />
      <button onClick={this.props.onClick}>{this.props.buttonLabel}</button>
    </div>
  );
}

In this example use is basically a new keyword which is part of JSX and is syntactic sugar for yield () =>. Or it does not have to be exactly yield () =>, but something that sets the current template to be used.

@streamich
Copy link
Author

BTW, the idea comes from react-coroutine library, @alexeyraspopov might be interested in this discussion.

@streamich
Copy link
Author

I've created a list of use cases, which also includes use cases mentioned in react-coroutine library:

https://gist.github.com/streamich/68b4470a8365f7b7ac6b4f614cdf45d2

@thysultan
Copy link

thysultan commented Mar 4, 2018

@streamich The "Load data in steps" example is confusing when compared with the "Loading" example. Is the next yield meant to replace the previous one(in the case of the Loading example), or are they mean to all render(like the case of the "Load data in steps")?

@streamich
Copy link
Author

streamich commented Mar 4, 2018

@thysultan I've tweaked that example slightly, indeed it is not the most clear one, but what it shows is that you can fetch initial batch of users await fetch('/users/1') and then already render them while fetching more users await fetch('/users/2').

@thysultan
Copy link

@streamich How would something like this work https://jsbin.com/folotu/edit?js,console.

  1. Will it grow the list, like in the example.
  2. Update the list, i.e there would only ever be one active element that is updated as new data is received.

@streamich
Copy link
Author

streamich commented Mar 4, 2018

@thysultan

Grow the list:

async function * NumberList () {
  const numbers = [];
  for await (const number of asyncRandomNumbers()) {
    numbers.push(number);
    yield () => numbers.map((number, i) => <div key={i}>{number}</div>);
  }
}

ReactDOM.render(<NumberList />, el);

Show only the last number:

async function * NumberLatest () {
  for await (const number of asyncRandomNumbers()) {
    yield () => <div>{number}</div>;
  }
}

ReactDOM.render(<NumberLatest />, el);

@alexeyraspopov
Copy link

@streamich, thanks for tagging me here.

Then how would the component be able to update itself when its props are changed?

@j-f1, when Coroutine receives new props, and they are new (react-coroutine uses shallowequal), the whole generator/async function/async generator is called again.

yield () => numbers.map(number => <div>{number}</div>);

@streamich I wasn't following, but what's the purpose of yielding a function (when you can just yield JSX)?

I use react-coroutine for quite long time already in my work, and here are some not trivial scenarios.

  1. Streaming view content from an async generator.

You can find a code example here: https://github.com/alexeyraspopov/actor-system/blob/master/examples/react-application/modules/MessageMonitor.react.js#L7-L12

  1. Showing complex content step by step.

Since each yield produces content that replaces previous one in the view, you still able to yield the same component, possibly with different props. In one of my projects I have a data visualization component that requires data from two different data sources. One of those data sources takes way longer to fetch data from. So I initially render this component with "incomplete" data and then wait for another data to arrive, so I can re-render the component again.

import React from 'react';
import Coroutine from 'react-coroutine';
import SomeComplexViz from './SomeComplexViz.react';

export default Coroutine.create(SomeComplexVizContainer);

async function* SomeComplexVizContainer({ someId }) {
  const someData = await DataAPI.retrieve(someId);
  yield <SomeComplexViz first={someData} />
  const anotherData = await AnotherAPI.retrieve();
  return <SomeComplexViz first={someData} second={anotherData} />;
}

Note: receiving component should handle missing data on its own.

@streamich
Copy link
Author

@alexeyraspopov The purpose of yielding a function was so that the whole async generator does not have to be re-played again when props change, in this example:

async function * MyComponent() {
  yield (<div>Loading...</div>);
  const users = await fetch('/users');
  yield () =>
    <div>
      <UserList users={users} />
      <button onClick={this.props.onClick}>{this.props.buttonLabel}</button>
    </div>;
}

In the above example the idea was that if the async component already rendered UserList and <button> and props would change after that, it could use the last yielded function to "re-render" JSX.

@thysultan thysultan mentioned this issue Mar 5, 2018
@alexeyraspopov
Copy link

so that the whole async generator does not have to be re-played again when props change

Interesting... It was intentionally done in react-coroutine, but with checking new props with shallowequal.

@streamich
Copy link
Author

streamich commented Mar 5, 2018

You can still force the iterator to "re-play" using the key prop:

ReactDOM.redner(<MyComponent key="1" />, el);
ReactDOM.redner(<MyComponent key="2" />, el);

Or maybe it could be a rule that if the last yielded value is not a function the iterator will be re-played, otherwise the yielded function will be called on re-renders.

@thysultan
Copy link

@streamich I implemented a POC https://jsbin.com/hezaduq/edit?html,output in DIO based on this, it's an interesting pattern.

@streamich
Copy link
Author

streamich commented Mar 5, 2018

@thysultan I will take a look at it tomorrow.

On another note, one thing I have mixed feelings about is that these async generator render functions are basically "state containers". I am wondering if the new proposed createState() API could be implemented using these async generators, something like:

React.createState = (initialState) => {
  return async function * State () {
    // ...
    for await (const state of stateIterator)
      yield () => this.props.children(state, setState);
  };
};

@trueadm

@streamich
Copy link
Author

streamich commented Mar 5, 2018

@thysultan that was fast! What do you think about not needing the stateful components at all because your example could be rewritten as:

class Test extends Component {
  async * render () {
    let index = 0;
    while (index++ < 20) {
      await new Promise(resolve => setTimeout(resolve, 500));
      yield () => <div>index</div>;
    }
  }
}

Basically the generator function "contains" the state inside itself, in the index variable.

Any problems you ran into while implementing this?

@thysultan
Copy link

thysultan commented Mar 5, 2018

Yes, i think you can get radically creative with things like animations/user flows if you wanted.

async function * SignUp () {
	var step = 0
	var name = ''

	while (true) {
		if (step)
			yield <h1>Hello {name}</h1>
		else
			yield <form onSubmit={(e) => e.preventDefault(step++)}>
				<input placeholder="Name?" onInput={({target}) => name = target.value}>
			</form>
	}
}

Any problems you ran into while implementing this?

It was fairly simple mainly because there where prior abstractions that made it that way but i figure it is possible in future async™ React as well.


@alexeyraspopov With your work on "react-coroutine" how are you testing async generator functions in NodeJS?

@streamich
Copy link
Author

@thysultan I see he is using jest with stage-3 Babel preset.

@alexeyraspopov
Copy link

With your work on "react-coroutine" how are you testing async generator functions in NodeJS?

While async generators are not fully supported by Node.js, I transform them using Babel.

I see he is using jest with stage-3 Babel preset.

Probably I can now update the presets, it's been a while.

@AlicanC
Copy link

AlicanC commented Mar 9, 2018

I don't think async will work because React likes to do its own scheduling and AFAIK awaiting inside render() would break that.

IMO, making render() async and generator are completely different things and should be discussed separately.

What do you think?

@thysultan
Copy link

I don't think async will work because React likes to do its own scheduling and AFAIK awaiting inside render() would break that.

The new "Suspense" API is built on the predicate of awaiting inside render(via thrown Promises). So i'm sure React could also handle this if it wanted to(subject to implementation limitations i may not know about).

@streamich
Copy link
Author

I don't think async will work because React likes to do its own scheduling and AFAIK awaiting inside render() would break that.

You can already implement it in user space, also react-coroutine does that.

@AlicanC
Copy link

AlicanC commented Mar 9, 2018

awaiting inside render(via thrown Promises)

That's not "awaiting inside render()".

async render() {
  const { a, b } = this.props;
  const { c, d } = this.state;

  await promise;
  // <- Continues here after resolve
  // a, b, c, d will have old values if they are changed before promise resolves
}

render() {
  const { a, b } = this.props;
  const { c, d } = this.state;

  throw promise;
  // <- Can't continue even after resolve
  // render() will be called again and will have fresh props and state if they are changed
}

render() is completely sync and making it async is a whole different thing than throwing promises.

@AlicanC
Copy link

AlicanC commented Mar 11, 2018

When you WorkA -> await/yield -> WorkB how do you know "WorkA" is still valid when you are doing WorkB?

If work = (input) => output and if work() is idempotent (same output for same input) then we can say that the old work is valid if the inputs didn't change.

What are the inputs?

FC: Functional Component
CC: Class Component
Outer X: X in outer scope

  • FC: Props, Outer Constants, Outer Variables
  • CC: Props, State, Instance (this), Outer Constants, Outer Variables

In FCs, Props can be checked by React, Outer Constants do not have to be checked, Outer Variables break your code.
In CCs, same + State can be checked by React, Instance breaks your code.

Let's assume we have a "Don't use Outer Variables and this" rule, then we can say that old work is valid if Props and/or State didn't change.

The problem with await is that React can't stop render() in such a case:

class extends Component {
  static async render(props, state) {
    const inputA = props.input;

    const outputA = workA(input);

    const inputB = await getInputB();

    // We are here and `props.input` have changed which makes outputA invalid.
    // How does React stop render() from continuing?
    // It can't.
    // You would be doing useless work which blocks the main thread.
    
    const outputB = workB(outputA, inputB);

    return outputB;
  }
}

But when you yield, it can:

class extends Component {
  static *render(props) {
    const inputA = props.input;

    const outputA = workA(input);

    const inputB = yield getInputB();

    // `props.input` have changed which makes outputA invalid.
    // React throws away the old Generator and calls render() again.
    // We never arrive here.
    
    const outputB = workB(outputA, inputB);

    return outputB;
  }
}

(If I'm not terribly mistaken) current suspense mechanism always discards old work, which could be still valid. Generators could help in performance and would be better than suspense when used correctly:

class extends Component {
  static *render(props) {
    const inputsA = props.inputsA;

    const outputsA = [];
    for (const inputA of inputsA) {
      const output = syncWorkA();
      outputsA.push(output);

      // syncWorkA() was expensive
      // Yield so React can work on other stuff
      yield;
    }

    // getInputB() is async
    // Yield promise so React awaits and continues with result
    const inputB = yield getInputB();
    
    const outputB = workB(outputA, inputB);

    return outputB;
  }
}

Spend some more time on this and we could turn it into two RFCs: "Static render() for Class Components" and "Generator render()".

I saw static render() already being discussed, so maybe we can just write the generator RFC assuming static render() is already available.

If we go back to await, React team spent a lot of time to implement their own scheduling mechanism to control asynchronisation and they had good reasons to do so. await takes control away from React and gives it to the VM. It doesn't seem to me that react-coroutine or any other library has figured out a way to do await without serious pitfalls or performance disadvantages.

On the other hand, implementation of suspense implies that React team needs a way to make render() async. Suspense doesn't suspend and actually stops. With generators you can truly suspend and that is an improvement over suspense.

@thysultan
Copy link

The assumption is that it can, and would prioritize fresh work, i.e given the following

async function * Foo ({children, time}) {
	yield wait(h('h1', 'Loading'), time)
	yield h('h1', children)
}

render(h(Foo, {children: 'Hello', time: 200}), container)
render(h(Foo, {children: 'World', time: 100}), container)

The last value ("Loading" -> "World") pair would render instead of the last resolved value("Hello"). This escapes the problem of stale data. That is – new(resolved) work can invalidate old stale work that is pending – which is how this is handled in the mentioned PR

@streamich
Copy link
Author

@AlicanC Why are you doing work*() in the render method, IMO render functions should simply return React element given props.

@AlicanC
Copy link

AlicanC commented Mar 11, 2018

Why are you doing work*() in the render method, IMO render functions should simply return React element given props.

Because that is the only place you can do work in a Functional Component. If we can do async or expensive sync work in render() than there will be less cases where we must use Class Components.

I have basically tried to give Suspense a generator frontend. If we want both await promise and yield element then we still could without await:

function* MyComponent(props) {
  yield <div/>; // Renders intermediary element
  const data = yield promise; // Emulates "await promise"
  return <OtherComponent data={data} />;
}

@alexeyraspopov
Copy link

@AlicanC, It's quite difficult to separate yielding JSX and "data" (either data or promises). It also brings more confusion for those who read the code: "are we awaiting or there will be intermediate content?"

React Coroutine supports both generators and async generators. For generators it expects yielding "possible promise" (like was presented by Dan) and the returned result is rendered. For async generators, you can yield intermediate content and then await for some data (either by await or even for..await..of) which allows to yield new content.

@mooonX
Copy link

mooonX commented Apr 29, 2018

ظت

@sonhanguyen
Copy link

looks like stateful component can be implemented as a generator so there is no need for the class syntax I think.

@alexeyraspopov
Copy link

There are some examples of making stateful component using async generator in react-coroutines examples folder

@donaldpipowitch
Copy link

Even with hooks I'd still like to see something like this/something like https://github.com/Astrocoders/epitath to flatten certain render calls as it supports if/else logic which you sometimes need 👍

@gaearon
Copy link
Member

gaearon commented Aug 24, 2021

Please file proposals as PRs, not issues.

@gaearon gaearon closed this as completed Aug 24, 2021
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

9 participants