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

RFC: accept promises as props to native DOM elements #225

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

mismosmi
Copy link

Full text here

  • Start Date: 2022-08-26
  • RFC PR: (leave this empty)
  • React Issue: (leave this empty)

Summary

React should accept promises as props and text nodes for native DOM-elements.
While a passed promise has not resolved yet, react should suspend the tree from the DOM-element upwards.
When one such promise resolves, react should trigger a re-render just like it
does with the regular suspense API.

Basic example

const myValue = React.useMemo(() => fetch("/my-api"), [])

return (
    <React.Suspense fallback={<h1 />}>
        <h1>{myValue}</h1>
    </React.Suspense>
)

Motivation

This API would significantly improve the developer experience when building
optimistic UIs. React's real strength compared to other frameworks such as Preact,
Svelte and server-side MVC frameworks such as Phoenix, Laravel and Spring Boot
is that it can handle client side state at scale and therefore support
large application-like interfaces. For application-like interfaces, optimistic UI is
imperative to provide a great user experience.

Passing promises directly to native DOM elements would make it possible to shift
suspending from the component where a fetch call is made towards the actual DOM elements
where the fetched values are needed:

old:
<Profile> -> fetches user profile data, suspends
  <ProfileLayout>
    <Avatar />
    <Name />
    <Address />
  </ProfileLayout>
</Profile>

new:
<Profile> -> fetches user profile data
  <ProfileLayout>
    <Avatar /> -> suspends
    <Name /> -> suspends
    <Address /> -> suspends
  </ProfileLayout>
</Profile>

This would make it possible to optimistically render a parent component that fetches data
and add suspense boundaries much further down the DOM tree just with react's built-in APIs.

Another motivation for this is to prevent data-fetching waterfalls. With this API, it would
be much easier to render all data-fetching components at once while suspending only the
parts of the tree that actually use the fetched data:

// would usually need to suspend to wait until profile is fetched
function App() {
    const profile = React.useMemo(() => fetch("/profile"), [])

    return (
        <>
            <Header profile={profile} />
            <main>
                <OrganizationPage profile={profile} />
            </main>
        </>
    )
}

function Header(props) {
    return (
        <div>
            <nav />
            <React.Suspense fallback={<img src="/avatar-placeholder.svg" />}>
                <img src={props.profile.then(profile => profile.avatarUrl)} />
            </React.Suspense>
        </div>
    )
}

function OrganizationPage(props) {
    const organization = React.useMemo(() => fetch("/organization"), [])

    return (
        <div>
            <React.Suspense fallback={<h1 />}>
                <h1>{organization.then(org => org.name)}</h1>
            </React.Suspense>
            <h3>Members</h3>
            <table>
                <thead>
                    <tr>
                        <th>Name</th>
                        <th>Phone</th>
                    </tr>
                </thead>
                <tbody>
                    <React.Suspense fallback={<TableRowSkeleton />}>
                        {organization.then(org => org.members.map(member => (
                            <tr>
                                <td>{member.name}</td>
                                <td>{member.phone}</td>
                            </tr>
                        )))}
                    </React.Suspense>
                </tbody>
            </table>
        </div>
    )
}

This would, of course, also work very nicely with server components, where much more of the DOM could
be streamed to the client before an actual data-fetching call resolves.

Detailed design

It is really pretty simple from a high-level point of view:
If a native element receives a promise as a prop or child, it suspends.
Once the promise resolves, it renders the resolved value.

Drawbacks

From my point of view the largest drawbacks are

  • it increases the API surface of the react library and this feature might be out of scope.
  • it would be a departure from the concept of having native html elements in JSX behave as closely like
    real HTML as possible.
  • it introduces additional logic in critical code paths, mainly a check if any value passed to
    a native element is a promise.

Alternatives

Apollo and other data-fetching libraries already enable optimistic rendering.
Something similar (but far not as elegant) can be implemented as a library and I'm working on that.

Adoption strategy

This is not a breaking change. Libraries could slowly adopt this strategy instead
of their custom optimisic rendering solutions.

How we teach this

I suppose this should become the recommended way to use suspense for data fetching and
would expect this to get its own section in the docs.

As gradual adoption is not a problem at all developers would learn about it through the
regular release notes and release channels. Consumers of data fetching libraries and frameworks
would probably learn about it through their library or framework's release notes.

I would e.G. love to have an option to have Next.js render a page immediately as soon as the
js-bundle is loaded and pass server side props as a promise to a page and would expect such features
to show up in Next.js' release notes.

Unresolved questions

¯_(ツ)_/¯

@sebmarkbage
Copy link
Collaborator

So, we actually have plans to probably do this. Working out the details now which will be related to another RFC.

@AndersDJohnson
Copy link

AndersDJohnson commented Nov 4, 2022

Is this made possible by the upcoming use hook? Can we pass the promises as props and resolve with use deeper in the tree? Will this work across client-server component boundaries as well?

const Parent = () => {
  const movieFetch = fetch('https://api.example.com/movie/123');

  return <Child movieFetch={movieFetch} />;
};

const Child = ({ movieFetch }) => (
  <div>
    Title: <Suspense fallback={<span>...</span>}>
      {use(movieFetch).title}
    </Suspense>

    <hr />

    Rating: <Suspense fallback={<span>...</span>}>
      {use(movieFetch).rating}
    </Suspense>
  </div>
);

@AndersDJohnson
Copy link

AndersDJohnson commented Nov 4, 2022

And, if what I suggest about use above is true...

Unless use already behaves the way I describe below, maybe we could come up with a more ergonomic interface. Perhaps using Proxy objects to dereference values with use implicitly - to make movie feel like a regular object, but property accessors wrap in use so that movie.title becomes use(movieFetch).title internally - something like a useLater wrapper hook:

const Parent = () => {
  const movie = useLater(fetch('https://api.example.com/movie/123'));

  return <Child movie={movie} />;
};

const Child = ({ movie }) => (
	<div>
      Title: <Suspense fallback={<span>...</span>}>
        {movie.title}
      </Suspense>
    </div>
);

Or maybe something like the old react-fetch would do this wrapping default so you wouldn't even notice - just write const movie = fetch('...').

Or maybe React could detect when promises originating from the monkeypatched fetch (or perhaps, promises in general) are passed as props, and automatically wrap before child receives. Not sure how to do that safely for backwards compatibility or valid use cases of passing down promises unrelated to use consumption, though.

Naming of useLater could be workshopped - maybe:

  • later
  • defer
  • useWrap
  • wrap
  • usable
  • suspendable
  • etc.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants