UseRef in Suspense data fetching

1.7k views Asked by At

I'm trying to use the experimental new React feature Suspense for data fetching.

Here's my simple useApi hook which (if I understand Suspense correctly) either returns the result of an fetch call or throws the suspender promise. (slightly modified the documented example)

function useApi(path) {
  const ref = React.useRef({ time: +new Date() });
  if (!ref.current.suspender) {
    ref.current.suspender = fetch(path).then(
      data => ref.current.data = data,
      error => ref.current.error = error,
    );
  }
  if (ref.current.data) return ref.current.data;
  if (ref.current.error) return ref.current.error;
  throw ref.current.suspender;
}

I'm using this hook simply like this:

function Child({ path }) {
  const data = useApi(path);
  return "ok";
}
export default function App() {
  return (
    <Suspense fallback="Loading…">
      <Child path="/some-path" />
    </Suspense>
  );
}

It never resolves.

I think the problem is that useRef isn't quite working as it's supposed to.

If I initialize the ref with a random value, it doesn't retain that value, and instead gets reinitialized with another random value:

const ref = React.useRef({ time: +new Date() });
console.log(ref.current.time)
1602067347386
1602067348447
1602067349822
1602067350895
...

There's something weird about throwing the suspender that causes the useRef to reinitialize on every call.

throw ref.current.suspender;

If I remove that line useRef works as intended, but obviously Suspense doesn't work.

Another way I can make it work is if I use some sort of custom caching outside of React, like:

const globalCache = {}
function useApi(path) {
  const cached = globalCache[path] || (globalCache[path] = {});
  if (!cached.suspender) {
    cached.suspender = ...
  }
  if (cached.data) ...;
  if (cached.error) ...;
  throw cached.suspender;
}

This also makes it work, but I would rather use something that React itself provides in terms of caching component-specific data.

Am I missing something on how useRef is supposed to, or not supposed to work with Suspense?

Repro: https://codesandbox.io/s/falling-paper-shps2

2

There are 2 answers

1
Dennis Vash On BEST ANSWER

Let's review some facts on React.Suspense:

  1. The children elements of React.Suspense won't mount until the thrown promise resolved.
  2. You must throw the promise from function body (not from a callback like useEffect).

Now, you throwing a promise from your custom hook, but according to 1. the component never mounts, so when the promised resolves, you throwing the promise again - infinite loop.

According to 2., even if you try saving the promise in a state or ref etc. still it wont work - infinite loop.

Therefore, if you want to write some custom hook, you indeed need to use any data-structure (can be managed globally {like your globalCache} or by React.Suspense parent) which indicates if the promise from this specific React.Suspense has been thrown (thats exactly what Relay does in Facebook's codebase).

0
mycroes On

I've been struggling with the same problem, but I think it's actually possible to achieve what you want. I looked at the implementations of react-async and SWR and noticed that react-async actually doesn't throw on the first render, but it uses useEffect(...) to start the async operation, combined with a setState which triggers another render and then throws the promise on subsequent renders (until it resolves). I believe SWR actually behaves the same, with one minor difference; SWR uses useLayoutEffect (with fallback to useEffect for server side rendering), which has one major benefit: the initial render without data never happens.

It does mean that the parent component still has to cope with abundance of data. The first render can be used to start the promise, but still has to return without throwing to avoid the infinite loop. Only on second render will the promise be thrown which will actually suspend rendering.