swr icon indicating copy to clipboard operation
swr copied to clipboard

[RFC] useSWRSuspense (or Suspense guards)

Open shuding opened this issue 4 years ago • 22 comments

With useSWRSuspense you can avoid waterfall in suspense mode:

function App () {
  const [user, movies] = useSWRSuspense(()=> {
    const { data: user } = useSWR('/api/user')
    const { data: movies } = useSWR('/api/movies')
    return [user, movies]
  })

  // or
  // const [user, movies] = useSWRSuspense(swr => [swr('/api/user'), swr('/api/movies')])

  return (
    <div>
      Hi {user.name}, we have {movies.length} movies on the list.
    </div>
  )
}

Note that swr function is the exact same as useSWR, it's for avoiding linter errors

It will maximize the parallelism while under suspense mode. In the example above both APIs will start to fetch at the same time, and the actual render will be suspended until both are loaded.

Another proposal

Another way is to use 2 "Suspense guard" hooks:

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

  return (
    <div>
      Hi {user.name}, we have {movies.length} movies on the list.
    </div>
  )
}

Which does the same thing (but it might be easier for users to make mistakes).

Checklist

  • [ ] make sure it works in concurrent mode
  • [ ] better error handling
  • [ ] error if the callback throws a promise (misuse of suspense: true inside useSWRSuspense)
  • [ ] documentation

Fixes #5.

shuding avatar Dec 02 '19 18:12 shuding

Thank you for prioritizing this feature ❤️


In my head, the API should look something like below

function MyComponent(props) {
    // First we declare the list of fetch calls. Here we use an object. Could be an array instead.
    // Doing this will NOT trigger suspense. i.e. A promise shall not be thrown.
    // But the API calls will be triggered in parallel  
    const fetcher = useSuspenseSwr({
         key1: {/*fetch config for key 1*/},
         key2: {/*fetch config for key 2*/},
    });

    // and then call fetcher.fetch('key1') somewhere in the code
    // This is when the promise will be thrown
}

This way, the API calls are triggered immediately, and parts of the component which are not dependent on the API calls can render.


The problem I see in your API is that the fetch calls are completely blocking the render. It does avoid the waterfalls, but it does not give the full power of suspense to the users.

pupudu avatar Dec 02 '19 23:12 pupudu

Thank you again for the amazing features and support!

If possible, I would write the API's like this:

export function App() {
  const [user, movies] = useSWR(
    ['/api/user', '/api/movies'], { suspense: true }
  );

// or const { user, movies } = useSWR(['/api/user', '/api/movies'], { suspense: true });
// or const [user, movies] = useSWR(swr => [swr('/api/user'), swr('/api/movies')], { suspense: true });

  return (
    <Suspense fallback={<Spinner />}>
      <div>
        Hi {user.name}, we have {movies.length} movies on the list.
      </div>
    </Suspense>
  );
}

Personally, I would prefer keeping the hook naming "clean" (sticking with just useSWR) by sticking with this commonly seen format ->

fetch(url: string, options: Object)

It would seem to make more sense to keep the suspense option in an options object, but I don't know what the consequences would be to the API. For example, what would the functionality be if a user were to have two fetches, but was missing the Suspense option? Do we just fetch two things before rendering like normal?

samcx avatar Dec 03 '19 01:12 samcx

@samsisle what you proposed is somehow possible right now:

function fetcher(...urls) {
  return Promise.all(url.map(url => fetch(url).then(res => res.json())));
}

const { data } = useSWR(["/api/user", "/api/movies"], fetcher)
const [user, movie] = data;

Something like that, your fetcher could be technically anything and don't necessarily needs to do a single request so you could read your array of URLs and run multiples fetches.

Note this will mean if you change something in one of those URL (e.g. a param or query) all of them will be fetched again. The proposal useSWRSuspense should avoid that from what I saw, since it will let you still have multiple useSWR calls, one per URL.

@quietshu the second option is not so easy to understand, it's probably simple but I think I can get a sense on how the first one works just by seen the example, but with the second one I'm not sure.

sergiodxa avatar Dec 03 '19 14:12 sergiodxa

@sergiodxa for the second example, it works like this under the hood:

useSWRSuspenseStart()                           // promises = []
const { data: user } = useSWR('/api/user')      // promises.push(fetch('/api/user'))
const { data: movies } = useSWR('/api/movies')  // promises.push(fetch('/api/movies'))
useSWRSuspenseEnd()                             // throw Promise.race(promises)

shuding avatar Dec 03 '19 14:12 shuding

The reason I'm not using

  const [user, movies] = useSWR(
    ['/api/user', '/api/movies'], { suspense: true }
  );

is because it loses the ability to do dependent fetching, meaning I can't do this in suspense mode:

image

(source)

But a function will still have the same expressiveness and parallelism (+ ability to be suspended by the boundary):

  const [user, movies] = useSWRSuspense(()=> {
    const { data: user } = useSWR('/api/user')
    const { data: posts } = useSWR('/api/posts')
    const { data: movies } = useSWR(() => '/api/movies?id=' + user.id)
    return [user, posts, movies]
  })

shuding avatar Dec 03 '19 15:12 shuding

Couldn't this be done just by splitting things up?

  • If requests are not dependent on each other, they can be done in separate components.
  • If they are dependent, they can't be done in parallell anyways, and need to be done sequentially, which is how multiple useSWR( , { suspense: true }) works now, I believe?

Svish avatar May 26 '20 09:05 Svish

@Svish I agree that ideally you should split your components and call it a day, however sometimes it's not possible to split things up.

I'm using a beta version of SWR with this feature because in a component I have multiple requests that are needed to render the UI, but they don't depend on each other, so they could totally be run simultaneously, without this feature I was causing an unnecessary waterfall of requests.

sergiodxa avatar May 26 '20 10:05 sergiodxa

Can we ressurect this? I'm using swr for react-three-fiber (essentially vr application). In that ecosystem, suspense is heavily used, but waterfalling requests is a pretty much a show stopper for me. I'd be happy to help with testing/contributing

saitonakamura avatar Sep 20 '21 14:09 saitonakamura

So a feedback on the proposals: each of them has a certain flaw (well, they are already listed in the starting comment, but I think elaborating won't hurt)

  1. useSWRSuspense with a callback that calls useSWR - looks the best, flexible (existing hooks that wrap useSWR can be reused), but it's gonna produce a linter warning about a rules of hooks every time

  2. useSWRSuspense with a callback that uses swr - no linter error, but no reuse of existing wrapped useSWR hooks usage (or a refactoring work to allow them to accept it from the outside)

  3. useSWRSuspenceStart/End - Look kinda clunky, but the hooks can be reused and no linter errors

If you ask me, I would go for a third one, I favor flexibility and reusability and rely on linters heavily

@sergiodxa you said that you're using a beta that has this? Can you point where I can find this beta version if it still exists?

saitonakamura avatar Sep 20 '21 14:09 saitonakamura

Thanks for the feedback @saitonakamura! Personally I like the third one as well, but I'd wait until Suspense & react-fetch (official data fetching lib with Suspense) get finalized first and then continue with this feature.

shuding avatar Sep 21 '21 20:09 shuding

@saitonakamura I'm not using it anymore, the beta version for this is really old too, way before v1 of SWR and I don't remember the version to use in the package.json and I can't check in the project repository anymore.

The issues you mentioned are things I saw, linter complaining all the time, but I was able to use it custom hooks calling SWR internally

sergiodxa avatar Sep 21 '21 22:09 sergiodxa

@shuding waiting for suspense finalization makes sense (especially in vanilla react world). Although, if you're afraid of people adopting it, I guess it can be released under unstable_ prefix like react team does

saitonakamura avatar Sep 24 '21 08:09 saitonakamura

What about this API?

function App() {
  // Start fetching the user data without suspending.
  const userResource = useSWRSuspense('/api/user')

  // Start fetching the movies data without suspending.
  const moviesResource = useSWRSuspense('/api/movies')

  // Throws the actual promise, suspends until the user data has loaded.
  const { data: user } = userResource.read()

  // Suspend until the movies data has loaded.
  const { data: movies } = moviesResource.read()

  return (...)
}

No hook lint errors, no waterfalls, data guaranteed to be available after the call to read().

If you want to do dependent fetching on more than one resource at the same time, only use suspense for the last hook call in the dependency chain.

johanobergman avatar Sep 26 '21 15:09 johanobergman

@johanobergman It looks great! One limitation comparing to the original proposal is missing the ability to do "Promise.race", for example:

useSWRSuspenseStart()

const { data: foo } = useSWR('/foo')
const { data: bar } = useSWR('/bar')

// either `foo.id` or `bar.id` is ok here
const id = foo ? foo.id : bar ? bar.id : null
const { data: baz } = useSWR(id ? '/baz?id=' + id : null)

useSWRSuspenseEnd()

When the dependency gets more complicated, there is no easy way to be as parallelized as this API. The "preload + read" approach somehow still "defines" the order before blocking, while the wrapper solution works like a solver.

I'm not sure if this is critical in real-world applications though, but still a limitation.

shuding avatar Sep 26 '21 16:09 shuding

@shuding Maybe make the api both suspense and not suspense at the same time?

function App() {
  // fooData and barData are only meant for dependent fetching, can be undefined.
  const { resource: fooResource, data: fooData } = useSWRSuspense('/foo')
  const { resource: barResource, data: barData }  = useSWRSuspense('/bar')

  const id = fooData ? fooData.id : barData ? barData.id : null
  const { resource: bazResource } = useSWRSuspense(id ? '/baz?id=' + id : null)

  // Every fetch is started, so we can block here.
  const { data: foo } = fooResource.read()
  const { data: bar } = barResource.read()
  const { data: baz } = bazResource.read()

  return (...)
}

johanobergman avatar Sep 26 '21 16:09 johanobergman

Maybe there's no need for a useSwrSuspense hook at all, you could just include a "suspense handle" in every call to useSwr.

function App() {
  // Regular useSWR() calls, returns an optional suspense resource/handle as well as the data.
  const { resource: fooResource, data: fooData } = useSWR('/foo')
  const { resource: barResource, data: barData }  = useSWR('/bar')

  const id = fooData ? fooData.id : barData ? barData.id : null
  const { resource: bazResource } = useSWR(id ? '/baz?id=' + id : null)

  // Use read() to block, or check loading states as normal.
  const { data: foo } = fooResource.read()
  const { data: bar } = barResource.read()
  const { data: baz } = bazResource.read()

  // To make it (possibly) more explicit, you could read through a "blocker" function instead.
  const [foo, bar, baz] = suspenseBlocker([fooResource, barResource, bazResource])

  return (...)
}

johanobergman avatar Sep 26 '21 17:09 johanobergman

Yeah that's a great idea too! 👍

shuding avatar Sep 26 '21 23:09 shuding

Althought probably pretty late to the party, this is the API that I ended up with when working on resolving the same issue on my vue port of SWR:

<script setup>
import { querySuspense } from 'vswr'

// Notice we don't use await here, and the result of those `querySuspense`
// are plain promises.
const first = querySuspense('/some/query') // Imagine this takes 2 seconds
const second = querySuspense('/some/other/query') // Imagine this takes 2 seconds

// This await will block until both resolve, but both already
// started fetching so the blockage is 2 seconds instead of
// 4 seconds if we awaited each one individually.
const [{ data: firstData }, { data: secondData }] = await Promise.all([first, second])
</script>

<template>
  <!-- Do something with firstData and secondData -->
</template>

More: https://github.com/ConsoleTVs/vswr#parallel-suspense

ConsoleTVs avatar Mar 01 '22 03:03 ConsoleTVs

is this PR... suspended?

mbrevda avatar Dec 26 '22 13:12 mbrevda

I am also curious about progress on this PR

msdrigg avatar Mar 22 '23 03:03 msdrigg

Any update on this?

bayraak avatar Jul 27 '23 22:07 bayraak

@huozhi Do you know any updates on this? You seem to be active here :)

LarsFlieger avatar Dec 04 '23 13:12 LarsFlieger