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

React.lazy() #64

Merged
merged 3 commits into from
Oct 23, 2018
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions text/0000-lazy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@

Start Date: 2018-10-19
- RFC PR: (leave this empty)
- React Issue: (leave this empty)

# Summary

`React.lazy` adds first-class support for code splitting components to React. It takes a module object and returns a special component type.

>Note
>
>This RFC is intentionally scoped to supporting default imports. We may in the future submit another RFC concerning named imports. In the meantime you can also implement support for named imports in userland with a lower-level Suspense API (to be discussed in another RFC).

# Basic example

```js
import Input from './Input'; // Regular import
const Button = React.lazy(() => import('./Button')); // Dynamic import

function Dialog() {
// You can mix normal and lazy-loaded components in one tree
return (
<form>
<Input />
<Button />
</form>
);
}
```

The first time `<Button />` is rendered, it would trigger the dynamic `import` which would start loading the code. When the `Button` code has loaded, React would resume rendering.

Note that this **wouldn't** immediately render a spinner or an empty space instead of the `<Button />` like userland solutions would do today. Instead, the render would get *suspended*. You would have full control over how far above in the tree to put the loading indicator, as well as an opportunity to skip the indicator altogether on fast connections. This is a part of the "Suspense" feature umbrella. The RFC specifying how Suspense itself works exactly is not published yet, but you can find a demonstration in the [second half of this talk](https://reactjs.org/blog/2018/03/01/sneak-peek-beyond-react-16.html). Specifying the exact mechanics of Suspense is orthogonal to this RFC and doesn't need to block it.

# Motivation

Code splitting is one of the most effective ways to reduce the size of client-side code. It is achievable in React today but it requires either managing state manually or using a library that does it for you. However, even with either of these solutions, the typical user experience isn't ideal.

Existing code splitting solutions tend to put the loading indicator (or a "hole") directly in place of the loaded component. As a result, you might end up with a cascade of spinners and "holes" as different leaf components are being loaded. Both spinners and "holes" make the loading sequence feel more janky, and cause unnecessary layout work for the browser, making the app load slower. They are hard to orchestrate and handle in a visually consistent way so often people don't use code splitting at all, or only reserve it for the top-level components. However, leaf components (e.g. a complex text input) benefit from code splitting too.

The goal of this API is to make it easy to code split any particular component regardless of whether it's closer to the root or to the leaves. Adding code splitting to a component shouldn't require you to restructure your components or their data flow. The Suspense API (to be discussed in a separate later RFC) would let you declaratively specify where to display the loading indicator, and use it both for code splitting and data fetching. React would make sure that only intentional loading states are displayed, and that async component boundaries aren't creating visual noise in the app loading sequence.

# Detailed design

`React.lazy` accepts a Promise factory, and returns a new component type. When React renders that type for the first time, it triggers the Promise factory (thus, in case of dynamic `import`, starting the request). If the Promise is fulfilled, React reads the `.default` value from it (assuming the resolved value is a module object), and uses it as a component type for rendering. If the Promise is rejected, the rejection is handled in the same way as React normally handles errors (by letting the nearest error boundary handle it). After the code has loaded, React caches the Promise result. Next renders of the components with this type become synchronous and have no extra cost.

## Why the `.default` Field?

You might be wondering why it reads the `.default` field from the Promise result rather than, for example, assume that the Promise result _itself_ is a component type. There are two reasons.

We propose to read `.default` automatically so that we can write code like this:

```js
const Button = lazy(() => import('./Button'));
```

and not like this:

```js
// Annoying and confusing:
const Button = lazy(() => import('./Button').then(Button => Button.default));
Copy link

@streamich streamich Oct 19, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does not have to be written like that. Could be

const Button = lazy(async () => (await import('./Button')).default);

or even

const Button = lazyDefault(() => import('./Button'));

or

const Button = lazy(() => import('./Button'), 'default');

or

const Button = lazy(() => import('./Button').then(_ => _.default));

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It most definitely needs to be a function that returns a promise, so the last 3 examples are invalid.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@milesj thx, corrected

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it'll need the intermediate function so the dynamic import isn't immediately resolved.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, without the function it's a side-effect.

// Named imports don't make this better:
const Button = lazy(() => import('./Button').then(Button => Button.Button));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is better

const Button = lazy(() => import('./Button').then(module => module.Button));

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The motivation for why it's not this way is explained just below. Did you get a chance to read it?

```

(Note this doesn't mean you're forced to use default exports for *all* your components. Even if you primarily use named exports, consider default exports to be "async entry points" into just the components you want to code split.)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds inconsistency in modules style. The point of component code splitting to be invisible. You take any module, wrap it with lazy and it becomes loadable. Converting to default export is additional and wrong action. We don't need to define that module as asynchronous. Module should be only consumed as asynchronous in place.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The motivation for this is explained just below.


The second part of the question is why don't we still _allow_ passing something without a `.default` property?

```js
// Why don't we want to support this though?
const Button = lazy(async () => {
const Components = await import('./components');
// Resolve to named export:
return Components.Button;
});
```

We **intentionally** don't support this in the scope of this proposal. This gives React an opportunity to integrate more tightly with the module system on the server in the future. There are no proposed standards for this yet, but the new Suspense-capable React server renderer we'll soon be working on will benefit from having a chance to introspect the `import()` result directly (rather than just a component). For example, it could change a priority of a pending request for the `Button.js` module once it knows that a component of the `Button` type is going to be lazily rendered. We can't do this if we break the link between the module and the component. While this depends on future experimentation and standardization work, this design leaves more space for it. We can always remove this restriction later if it doesn't end up being beneficial. (That's also why the proposal doesn't support arbitrary Promises as component types.)

### Named Exports

While named exports aren't currently supported by this proposal, it doesn't exclude them in the future. For example:

```js
// Not a part of this RFC but plausible in the future
const Button = lazy(() => import('./components'), components => components.Button);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is nice, but we need something right now. Would be good to add at least an ugly example.

const Button = lazy(() =>
  import('./components').then(module => ({ default: module.Button }))
);

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think maybe we’ll try to warn against that pattern since it will break future work

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't get. How this will break future work? And how are you gonna forbid this? It's just a promise with object. We need a solution now. Using default exports for async components is awful workaround.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you feel so strongly against writing a single line of code that does the export?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now default exports are discouraged. There are a lot of reasons behind:

  • like allowSyntheticDefaultImports:false in typescript
  • like autoimports in IDE
  • like auto "naming" things

Why not to support basic "babel" interop, ie use .default if _esModules set, mimicking how "real"(babel/webpack) imports work?

I also prefer to have a default export on code splitting point, as some sort of an "interface", but it should not be the law.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Babel interop has made the ESM ecosystem very messy and complex (even though it was instrumental in getting people to adopt ESM — perhaps too early). We don’t wait to add more tooling dependency on this behavior because it’s super hard to fix properly.

I also prefer to have a default export on code splitting point, as some sort of an "interface", but it should not be the law.

See #64 (comment).

```

This is out of scope of the current initial proposal but could be added later.

# Drawbacks

* You can't render a `React.lazy` component with the current server renderer implementation because it doesn't support suspending. (That's the case for all Suspense features so it's not unique to this proposal.)
* Reading `.default` only can be annoying to teams that have settled on only ever using named exports.

# Alternatives

* Keep implementing this manually (status quo).
* Don't implement this at all until the Suspense-capable server renderer is done.
* Call it something long like `createLazyComponent`.
* Support Promises as component types directly.
* `lazy` renders a "hole" or lets you specify an indicator as part of its API.
* `lazy` doesn't try to read the `.default` export.
* `lazy` reads the `.default` export but also works with Promise resolving to a component directly.

# Adoption strategy

This is not a breaking change. However, until Suspense-capable server renderer is ready, you can only render `lazy` components in the client code paths. It works both in concurrent and in sync mode, and you can start adopting it as soon as Suspense itself is stable. (Suspense will be in a separate RFC.)

# How we teach this

We have a page on code splitting in the documentation. We will revamp it after Suspense is out to highlight the new built-in solution in React.