A Walkthrough of *that* React Suspense Demo

swyx - Mar 2 '18 - - Dev Community

Update from Nov 2018: The API's below are out of date, check https://github.com/sw-yx/fresh-concurrent-react for an up to date guide!


the suspense is real!


Bottomline up front: In this walkthrough of the 300ish line Movie Search demo, we learn the various aspects of the React Suspense API:

  • simple-cache-provider.SimpleCache - puts a cache in createContext
  • simple-cache-provider.createResource - which 1) takes a promise for your data and 2) outputs a function that takes a cache and an arg to call your promise (also called the suspender)
  • How to delegate updates to a lower priority with ReactDOM.unstable_deferredUpdates
  • How createResource loads data asynchronously by throwing Promises (!!!)
  • React.Timeout - just gives you a boolean for flipping between children and fallback
  • How to use createResource to do async image loading (!!!)

Read on if you want to learn React Suspense!


The Async React demo at JSConf Iceland lived up to the hype: Time Slicing and React Suspense are on the way! (See the official blogpost, video, and HN discussion for more). Watching the video is a prerequisite for the rest of this article!

Dev Twitter was buzzing with prominent devs working through the implications of Async React for everything from React-Loadable to React Router to Redux, and the always-on-the-ball Apollo Team even pushed out a demo app built with Async React and Apollo!

Needless to say, people were excited (read the whole thing, its hilarious):

And the spectrum.chat folks were veeery excited:

image

Heady stuff. This is the culmination of a years-long process, starting with this tweet from Jordan Walke in 2014, to Lin Clark's intro to React Fiber (where you see Time Slicing working almost a year ago), to the actual React Fiber release in Sept 2017, to Sebastian coming up with the suspender API in Dec 2017.

But if you're just a regular React-Joe like me, you're feeling a little bit left behind in all this (as it should be - this is advanced stuff and not even final yet, so if you're a React newbie STOP READING AND GO LEARN REACT).

I learn by doing, and am really bad at grokking abstract things just by talking about them.

Fortunately, Andrew Clark published a version of the Movie search demo on CodeSandbox! So I figured I would walk through just this bit since it's really all the demo usage code we have (apart from the Apollo demo which is a fork of this Movie search demo) and I didn't feel up to walking through the entire source code (I also happen to be really sick right now, but learning makes me happy :)).

Finally, some disclaimers because people get very triggered sometimes:

  1. I'm a recent bootcamp grad. You're not reading the divinings of some thought leader here. I'm just some guy learning in public.
  2. This API is EXTREMELY UNSTABLE AND SUBJECT TO CHANGE. So forget the specifics and just think about if the concepts make sense for you.
  3. If you're a React newbie YOU DO NOT NEED TO KNOW THIS AT ALL. None of this needs to be in any sort of React beginner curriculum. I would put this -after- your learning Redux, and -after- learning the React Context API

But learning is fun! Without further ado:

Diving into React Suspense

Please have the demo open in another screen as you read this, it will make more sense that way.


once again for the people who are skimming:

HEY! YOU! OPEN THE DEMO BEFORE YOU READ ON!


Meet simple-cache-provider.SimpleCache

The majority of the app is contained in index.js, so that's where we start. I like diving into the tree from top level down, which in the code means you read from the bottom going up. Right off the bat in line 303, we see that the top container is wrapped with the withCache HOC. This is defined in withCache.js:

import React from 'react';
import {SimpleCache} from 'simple-cache-provider';

export default function withCache(Component) {
  return props => (
    <SimpleCache.Consumer>
      {cache => <Component cache={cache} {...props} />}
    </SimpleCache.Consumer>
  );
}
Enter fullscreen mode Exit fullscreen mode

Here we see the second React API to adopt the child render prop (see Kent Dodds' recap for the first), and it simply provides a cache prop to whatever Component is passed to it. The source for simple-cache-provider comes in just under 300 lines of Flow-typed code, and you can see it uses createContext under the hood. You may have heard a lot of fuss about the "throw pattern", but this is all nicely abstracted for you in simple-cache-provider and you never actually have to use it in your own code.

Just because it really is pretty cool, you can check it out in line 187 where the promise is thrown and then called in the load function in line 128. We'll explore this further down.

Side Effects in Render

The main meat of the Movie Search demo is in the MoviesImpl component:

class MoviesImpl extends React.Component {
  state = {
    query: '',
    activeResult: null,
  };
  onQueryUpdate = query => this.setState({query});
  onActiveResultUpdate = activeResult => this.setState({activeResult});
  clearActiveResult = () => this.setState({activeResult: null});
  render() {
    const cache = this.props.cache;
    const state = this.state;
    return (
      <AsyncValue value={state} defaultValue={{query: '', activeResult: null}}>
      /*just renders more JSX here */
      </AsyncValue>
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

The first thing to notice is that there are no side effects outside of render. Pause to think about how you would normally do side effects in a React component - either do it in a lifecycle method like componentDidMount or componentDidUpdate, or in your event handlers like onQueryUpdate and onActiveResultUpdate above. How is this app updating as you type in queries in to the input box?

This is where things start to look really weird. The answer is in that AsyncValue component.

Meet ReactDOM.unstable_deferredUpdates

The answer, as with everything, is 42. Specifically, scroll up to line 42 to find the source of AsyncValue:

class AsyncValue extends React.Component {
  state = {asyncValue: this.props.defaultValue};
  componentDidMount() {
    ReactDOM.unstable_deferredUpdates(() => {
      this.setState((state, props) => ({asyncValue: props.value}));
    });
  }
  componentDidUpdate() {
    if (this.props.value !== this.state.asyncValue) {
      ReactDOM.unstable_deferredUpdates(() => {
        this.setState((state, props) => ({asyncValue: props.value}));
      });
    }
  }
  render() {
    return this.props.children(this.state.asyncValue);
  }
}
Enter fullscreen mode Exit fullscreen mode

ReactDOM.unstable_deferredUpdates is an undocumented API but it is not new, going as far back as Apr 2017 (along with unstable_AsyncComponent). My uneducated guess is that this puts anything in asyncValue (namely, query and activeResult) as a lower priority update as compared to UI updating.

Skipping MasterDetail, Header, and Search

Great! back to parsing the innards of AsyncValue.

      <AsyncValue value={state} defaultValue={{query: '', activeResult: null}}>
        {asyncState => (
          <MasterDetail
            header={<Header />} // just a string: 'Movie search'
            search={ // just an input box, we will ignore
            }
            results={ // uses <Results />
            }
            details={ // uses <Details />
            }
            showDetails={asyncState.activeResult !== null}
          />
        )}
      </AsyncValue>
Enter fullscreen mode Exit fullscreen mode

Nothing too controversial here, what we have here is a MasterDetail component with FOUR render props (yo dawg, I heard you like render props...). MasterDetail 's only job is CSS-in-JS, so we will skip it for now. Header is just a string, and Search is just an input box, so we can skip all that too. So the remaining components we care about are Results and Details.

Digging into simple-cache-provider.createResource

It turns out that both use similar things under the hood. Here is Results on line 184:

function Results({query, cache, onActiveResultUpdate, activeResult}) {
  if (query.trim() === '') {
    return 'Search for something';
  }
  const {results} = readMovieSearchResults(cache, query);
  return (
    <div css={{display: 'flex', flexDirection: 'column'}}>
       /* some stuff here */
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The key bit is readMovieSearchResults, which is defined like so:

import {createResource} from 'simple-cache-provider';

// lower down...

async function searchMovies(query) {
  const response = await fetch(
    `${TMDB_API_PATH}/search/movie?api_key=${TMDB_API_KEY}&query=${query}&include_adult=false`,
  );
  return await response.json();
}

const readMovieSearchResults = createResource(searchMovies);
Enter fullscreen mode Exit fullscreen mode

Note that the Results component is still in the "render" part of the overall app. We are passing the searchMovies promise to the new createResource API, which is in the simple-cache-provider source

Now createResource uses some dark magic I don't totally understand and isnt strictly necessary for the demo, but indulge me. The rough process goes from

This circuitous roundabout method is the core innovation of React Suspense and you can tell it's just a bit above my level of understanding right now. But that is how you achieve side effects inside of your render (without causing an infinite loop).

THIS IS THE KEY INSIGHT: "Suspense" is where readMovieSearchResults(cache, query) is used synchronously in the code example above. If the cache doesn't contain the results for your query (stored internally as a Map using a hash), it "suspends" the render and throws the promise.

Apollo and others will have alternative cache implementations.

Yikes, that was gnarly! Let me know in the comments if there's something I got wrong. I'm learning too.

So that's Results (mostly) done. On to Details!

The devil is in the Details

Actually, Details is just a thin wrapper around MovieInfo, which is defined on line 227:

function MovieInfo({movie, cache, clearActiveResult}) {
  const fullResult = readMovie(cache, movie.id);
  return (
    <Fragment>
      <FullPoster cache={cache} movie={movie} />
      <h2>{movie.title}</h2>
      <div>{movie.overview}</div>
    </Fragment>
  );
}
Enter fullscreen mode Exit fullscreen mode

readMovie is a similar cache call to readMovieSearchResults, it just calls that new createResource with a different URL to fetch. What I want to highlight is rather FullPoster:

function FullPoster({cache, movie}) {
  const path = movie.poster_path;
  if (path === null) {
    return null;
  }
  const config = readConfig(cache);
  const size = config.images.poster_sizes[2];
  const baseURL =
    document.location.protocol === 'https:'
      ? config.images.secure_base_url
      : config.images.base_url;
  const width = size.replace(/\w/, '');
  const src = `${baseURL}/${size}/${movie.poster_path}`;
  return (
    <Timeout ms={2000}>
      <Img width={width} src={src} />
    </Timeout>
  );
}
Enter fullscreen mode Exit fullscreen mode

Here we have a bunch of new things to deal with. readConfig is yet another cache call (see how we're just casually making all these calls as we need them in the render?), then we have some normal variable massaging before we end up using the Timeout and the Img components.

Introducing React.Timeout

Here's Timeout.js:

import React, {Fragment} from 'react';

function Timeout({ms, fallback, children}) {
  return (
    <React.Timeout ms={ms}>
      {didTimeout => (
        <Fragment>
          <span hidden={didTimeout}>{children}</span>
          {didTimeout ? fallback : null}
        </Fragment>
      )}
    </React.Timeout>
  );
}

export default Timeout;
Enter fullscreen mode Exit fullscreen mode

Yes, this is new (here's the PR to add it, its mixed in with a bunch of other React Fiber code so explore at your own risk). But its intuitive: Feed in a ms prop, which then controls a boolean didTimeout, which if true hides the children and shows the fallback, or if false shows the children and hides the fallback. The third React API to use a render prop, for anyone keeping count!

Pop quiz: why do this children/fallback behavior using <span hidden> rather than encapsulate the whole thing in {didTimeout ? fallback : children} and not have a <span> tag at all? Fun thing to consider if you haven't had to before (reply in the comments if you're not sure!)

On to the other thing.

Async Image Loading, or, how to make just passing a string not boring

Here's Img.js:

import React from 'react';
import {SimpleCache, createResource} from 'simple-cache-provider';
import withCache from './withCache';

function loadImage(src) {
  const image = new Image();
  return new Promise(resolve => {
    image.onload = () => resolve(src);
    image.src = src;
  });
}

const readImage = createResource(loadImage);

function Img({cache, src, ...props}) {
  return <img src={readImage(cache, src)} {...props} />;
}

export default withCache(Img);

Enter fullscreen mode Exit fullscreen mode

What's this! We're creating another cache! Yes, there's no reason we cant have multiple caches attached to different components, since we're "just" using createContext under the hood as we already established. But what we are using it -for- is new: async image loading! w00t! To wit:

  • use the Image() constructor (yea, I didn't know this was a thing either, read the MDN and weep)
  • wrap it in a Promise and set the src
  • pass this Promise to createResource which does its thing (don't even ask.. just.. just scroll up, thats all I got for you)
  • and when the loading is done, we pass it through to the <img src!

Take a moment to appreciate how creative this is. at the end of the day we are passing src, which is a string, to <img src, which takes a string. Couldn't be easier. But IN BETWEEN THAT we insert our whole crazy createResource process to load the image asynchronously, and in the meantime <img src just gets nothing to render so it shows nothing.

HELLO KEY INSIGHT AGAIN: We "suspend" our render if the cache does not have the hash for src, and throw the Promise, which doesn't resolve until the image gets loaded, which is when React knows to rerender Img again.

BOOM MIC DROP.

Does this look familiar? Passing a string now has side effects. This is just the same as passing JSX to have side effects. React Suspense lets you insert side effects into anything declarative, not just JSX!

Homework

There are only two more things to explore: Result and PosterThumbnail, but you should be able to recognize the code patterns from our analysis of FullPoster and Img now. I leave that as an exercise for the reader.

So taking a step back: What have we learned today?

  • simple-cache-provider.SimpleCache - puts a cache in createContext
  • simple-cache-provider.createResource - which 1) takes a promise for your data and 2) outputs a function that takes a cache and an arg to call your promise (also called the suspender)
  • How to delegate updates to a lower priority with ReactDOM.unstable_deferredUpdates
  • How createResource loads data asynchronously by throwing Promises (!!!)
  • React.Timeout - just gives you a boolean for flipping between children and fallback
  • How to use createResource to do async image loading (!!!)

That is a LOT packed into 300 lines of code! Isn't that nuts? I certainly didn't get this from just watching the talk; I hope this has helped you process some of the finer details as well.

Here are some other notable followups from the post-talk chatter:

For people who want to use createFetcher from the talk (although simple-cache-provider is the official implementation for now):

(read entire thread not just this tweet)

Want to see a createFetcher (without simple-cache-provider) in action? Jamie is on it in this sandbox demo

Need more demos? Dan Abramov is somehow still writing live examples (using his implementation of createFetcher):

If you are worried about multiple throws:

(read entire thread not just this tweet)

If you still aren't sure if throwing Promises are a good thing, you're not alone (this was supposed to be controversial!):

(read entire thread not just this tweet)

Why use Promises? What if I want to cancel my fetching? Why not generators? or Observables?

(read entire thread not just this tweet - Idempotence is the keyword)

Where can you -not- use suspend? Andrew Clark's got you:

(read entire thread not just this tweet)

What have I missed or got wrong? please let me know below! Cheers!


Edit March 27 2018

I am now rewatching the combined JSConf and ReactFest Demos to teas out the Suspense use cases. Here goes.

ReactFest

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .