Skip to content

Instantly share code, notes, and snippets.

@shuding
Created September 14, 2021 12:18
Show Gist options
  • Star 15 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save shuding/6ef6a85c4c8ee57d9926e705adef88e3 to your computer and use it in GitHub Desktop.
Save shuding/6ef6a85c4c8ee57d9926e705adef88e3 to your computer and use it in GitHub Desktop.
The Journey of SWR and Suspense

The Journey of SWR and Suspense

We are trying to combine the idea of SWR ("stale-while-revalidate") and React Suspense together, and this write-up covers all our findings from this journey.

Background

When React Hooks launched, people started to rely on useEffect to initiate data fetching:

function User () {
  const [user, setUser] = useState()
  const [error, setError] = useState()
  useEffect(() => {
    fetchUser()
      .then(setUser)
      .catch(setError)
  }, [])

  return <h1>{data?.name}</h1>
}

While there are multiple drawbacks of this approach:

  1. Every re-mounting of all <User/> instances will have the same data fetching triggered.
  2. Data can be inconsistent in different <User/> instances.
  3. Hard to add new data fetching logic that depends on user. When the depedency becomes more complicated, Promise.all and Promise.race need to be introduced (spaghetti code).
  4. Data can be undefined (data?.name), and there is no way for the parent to know if <User/> is still pending on data fetching.
  1. and 2) are critical for almost all use cases. When using client side routing and navigating to a previous page, you don't want the data to be missing until re-fetching ends. Another example is crypto wallet dashboards, differernt <Price/> instances on the page might display different results. And 3) is mostly a DX drawback, but also something easy to do wrong.

A strategy is to move all data fetching logic to the very top level (such as the router), and pass them down to the components that actually use them via Context or props. But the obvious downside is that the top level doesn't know about the resources needed for the page, most of the time they're dynamic.

SWR

SWR tries to solve 1), 2), 3), and potentially 4), with the "stale-while-revalidate" idea.

Conceptually, the useSWR() hook is not just data fetching like the useEffect() logic above, it represents "return the latest cached value (stale), while re-fetching it at the right time* and populate the cache (revalidate)".

With this definition we can safely solve 2) by introducing a global cache, and 1) by deduping concurrent requests with the same key:

function User() {
  const { data } = useSWR(resourceKey, fetchUser)
  return <h1>{data?.name}</h1>
}
// React Context: SWR Cache
<SWRCacheProvider>
  <User/>
  <User/>
  <User/>
</SWRCacheProvider>

Each SWR hook gets access to the shared cache. Only one request is fired but it will broadcast the state change to all hooks. When re-mounting <User/> inside the cache boundary, cached value will always be rendered immediately, while the latest value is being fetched again.

For dependent data fetching, since useSWR is declarative, we can simply pass null as the resource key if it's not ready:

const { data: foo } = useSWR(resourceKey, fetchFoo)
const { data: bar } = useSWR(foo ? resourceKey : null, () => fetchBar(foo.id))

Because React executes the render logic every time when a state changes, SWR doesn't need to know about the dependency relationship, it just checks the condition (more). And the resources will be fetched as early & parallelized as possible.

Suspense

React Suspense solved the problem 4) above by handing the pending state to the upper level, but the other problems are still remaining (note: some implementations have shared cache, so consistency is guaranteed in that case). But together with SWR we might be possible to solve all of them with some new ideas.

function User() {
  const { data } = useSWR(resourceKey, fetchUser, { suspense: true })
  return <h1>{data.name}</h1>
}

When passing suspense: true, SWR will ensure that the cache exists. If not, it throws the data fetching process as promise. This is very similar to other Suspense-based data fetching libs, except:

  • It deduplicates concurrent requests and throws the same promise.
  • It's still a hook, and can re-fetch and broadcast, then re-render with updated data without suspending again.

That means we will have the ability to suspend while retaining the reusability and reactivity. But we are still facing some new problems with this approach.

Problem: Waterfall and Dependent

This is a common problem with Suspense:

function Comp() {
  const d1 = foo.read()
  const d2 = bar.read()
  const d3 = baz.read(d1.id)
  
  ...
}

One way is to preload the data fetching (prefered by Relay) outside/before rendering the component. But still, it's tricky to deal with dynamic and dependent resources with preloading.

Attempt #1

Almost 2 years ago, our first attempt was to wrap those data fetching processes and handle the suspense logic together (vercel/swr#168):

const [user, movies] = suspenseGuard(()=> {
  const { data: user } = useSWR('/api/user')      // not Suspense-based
  const { data: movies } = useSWR('/api/movies')  // not Suspense-based
  return [user, movies]
})

We called it "Suspense Guard". Interally, suspenseGuard collects all the pending requests (as promises) of useSWR hooks inside it, and then throws away a Promise.race of all these promises. With this, we can avoid waterfalls but still have dependent resources.

Since it looks weird to put hooks inside a function call, so we've also considered the following alternative syntax:

useSWRSuspenseStart()
const { data: user } = useSWR('/api/user')
const { data: movies } = useSWR('/api/movies')
useSWRSuspenseEnd()

Attempt #2

This another approach is almost what we are doing today in production at Vercel. Similar to above, but it has better typing and linting support:

function Wrapper() {
  const { data: foo, promise: promiseFoo } = useSWR(resourceKey1, fetchFoo)
  const { data: bar, promise: promiseBar } = useSWR(resourceKey2, fetchBar)
  const { data: baz, promise: promiseBaz } = useSWR(
    foo ? 'baz?foo=' + foo.id : null, // Depends on `foo`
    fetchBaz
  )
  
  if ([foo, bar, baz].includes(undefined)) {
    throw Promise.race([promiseFoo, promiseBar, promiseBaz])
  }
  
  // All resources must be defined.
  return <Comp foo={foo} bar={bar} baz={baz} />
}

Downside of this approach is we have to expose those promises, and it's not as elegant as #1.

Attempt #3

Alternatively, we also thought about this implementation:

function Comp() {
  const { data: fooResource } = useSWR(resourceKey1, fetchFoo)
  const { data: barResource } = useSWR(resourceKey2, fetchBar)
  
  // Actually throws away the promises
  const foo = fooResource.read()
  const bar = barResource.read()
  
  ...
}

Like the "preloading" idea suggested by Relay, but it happens during the component render. However this API can't support complex dependent scenarios. If a resource baz which is depending on foo or bar (whatever resolves first), it can't be done this way:

  const { data: fooResource } = useSWR(resourceKey1, fetchFoo)
  const { data: barResource } = useSWR(resourceKey2, fetchBar)
  
  // Actually throws away the promises
  const foo = fooResource.read()
  const bar = barResource.read()
  
  const { data: bazResource } = useSWR('baz?foo=' + foo.id, fetchBar)
  const baz = bazResource.read()

However it's doable with approaches in #1 and #2.

Problem: Broadcasted Updates

This might be a UX decision to make: after everything resolves and the component being rendered, if foo.id has updated via a re-validation, because of stale-while-revalidate foo will always be returned. However baz becomes missing since the resource key changes (it depends on foo.id), and the component is turned into the suspending state again:

function Wrapper() {
  const { data: foo, promise: promiseFoo } = useSWR(resourceKey1, fetchFoo)
  const { data: bar, promise: promiseBar } = useSWR(resourceKey2, fetchBar)
  const { data: baz, promise: promiseBaz } = useSWR(
    foo ? 'baz?foo=' + foo.id : null, // Depends on `foo`
    fetchBaz
  )
  
  if ([foo, bar, baz].includes(undefined)) {
    throw Promise.race([promiseFoo, promiseBar, promiseBaz])
  }
  
  // All resources must be defined.
  return <Comp foo={foo} bar={bar} baz={baz} />
}

With SWR, it might end up with the weird behavior that when a new element mounts (re-fetchs the same resource), another place on the page becomes suspending.

Sometimes this is the desired behavior, but sometimes it's considered annoying. We're thinking about a new option to make the hook "laggy": if the resource key changes, we still return the previous value until the new one resolves. And the developer can enable/disable it based on the design:

const { data: foo, promise: promiseFoo } = useSWR(resourceKey1, fetchFoo)
const { data: bar, promise: promiseBar } = useSWR(resourceKey2, fetchBar)
const { data: baz, promise: promiseBaz } = useSWR(
  foo ? 'baz?foo=' + foo.id : null, // Depends on `foo`
  fetchBaz,
  { laggy: true }
)

All of these scenarios and problems are pretty common from our own products to the community, and it's not limited to just the SWR library. Since Suspense will be an important part of React 18, I think it's worth to share all these with the working group.

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