最新消息:雨落星辰是一个专注网站SEO优化、网站SEO诊断、搜索引擎研究、网络营销推广、网站策划运营及站长类的自媒体原创博客

javascript - Conditionally returning a React component to satisfy a Suspense fallback - Stack Overflow

programmeradmin3浏览0评论

Normally when I'm returning a ponent (I'm using nextjs 13) that relies on fetched data, I conditionally render elements to ensure values are available:

TableComponent:

export const Table = ({ ...props }) => {

    const [tableEvents, setTableEvents] = useState(null);

    useEffect(() => {
        fetchAndSetTableEvents();
    }, []);


    async function fetchAndSetTableEvents() {
        const fetchedEvents = await fetch(* url and params here *)
        if (fetchedEvents && fetchedEvents) {
            setTableEvents(fetchedEvents);
        } else {
            setTableEvents(null);
        }
    }

    return (
        <React.Fragment>
            <div>
                {tableEvents ? tableEvents[0].title : null}
            </div>
        </React.Fragment>
    )


};

If I try and load TableComponent from a parent ponent using Suspense, it loads but doesn't show the fallback before it's loaded:

<Suspense fallback={<div>Loading Message...</div>}>
    <TableComponent />
</Suspense>

However, if I remove the conditional rendering in TableComponent and just specify the variable, the fallback shows correctly while it's attempting to load the ponent:

return (
    <React.Fragment>
        <div>
            {tableEvents[0].title}
        </div>
    </React.Fragment>
)

But it ultimately fails to load the ponent as tableEvents is initially null and will vary on each fetch so it cannot have a predictable key.

The React docs for Suspense just show a simple example like this.

Conditionally returning the render also returns the ponent ok but fails to show the suspense fallback

if (tableEvents) {
    return (
        <React.Fragment>
            <div>
                {tableEvents[0].event_title}
            </div>
        </React.Fragment>
    )
}

Question

How do I fetch and return values in a ponent, that may or may not exist, that satisfy the criteria for a Suspense fallback to show when loading. I'm assuming it relies on a Promise in a way that I'm blocking but can't find a way around.

Normally when I'm returning a ponent (I'm using nextjs 13) that relies on fetched data, I conditionally render elements to ensure values are available:

TableComponent:

export const Table = ({ ...props }) => {

    const [tableEvents, setTableEvents] = useState(null);

    useEffect(() => {
        fetchAndSetTableEvents();
    }, []);


    async function fetchAndSetTableEvents() {
        const fetchedEvents = await fetch(* url and params here *)
        if (fetchedEvents && fetchedEvents) {
            setTableEvents(fetchedEvents);
        } else {
            setTableEvents(null);
        }
    }

    return (
        <React.Fragment>
            <div>
                {tableEvents ? tableEvents[0].title : null}
            </div>
        </React.Fragment>
    )


};

If I try and load TableComponent from a parent ponent using Suspense, it loads but doesn't show the fallback before it's loaded:

<Suspense fallback={<div>Loading Message...</div>}>
    <TableComponent />
</Suspense>

However, if I remove the conditional rendering in TableComponent and just specify the variable, the fallback shows correctly while it's attempting to load the ponent:

return (
    <React.Fragment>
        <div>
            {tableEvents[0].title}
        </div>
    </React.Fragment>
)

But it ultimately fails to load the ponent as tableEvents is initially null and will vary on each fetch so it cannot have a predictable key.

The React docs for Suspense just show a simple example like this.

Conditionally returning the render also returns the ponent ok but fails to show the suspense fallback

if (tableEvents) {
    return (
        <React.Fragment>
            <div>
                {tableEvents[0].event_title}
            </div>
        </React.Fragment>
    )
}

Question

How do I fetch and return values in a ponent, that may or may not exist, that satisfy the criteria for a Suspense fallback to show when loading. I'm assuming it relies on a Promise in a way that I'm blocking but can't find a way around.

Share Improve this question edited Jan 31, 2023 at 9:42 biscuitstack asked Jan 30, 2023 at 18:05 biscuitstackbiscuitstack 12.1k1 gold badge30 silver badges45 bronze badges
Add a ment  | 

2 Answers 2

Reset to default 17

TL;DR

For Suspense to be triggered, one of the children must throw a Promise. This feature is more aimed at library developers but you could still try implementing something for yourself.

Pseudocode

The basic idea is pretty simple, here's the pseudo-code

function ComponentWithLoad() {
  const promise = fetch('/url') // create a promise

  if (promise.pending) { // as long as it's not resolved
    throw promise // throw the promise
  }

  // otherwise, promise is resolved, it's a normal ponent
  return (
    <p>{promise.data}</p>
  )
}

When a Suspense boundary is thrown a Promise it will await it, and re-render the ponent when the promise resolves. That's all.

Problem

Except that now we have 2 issues:

  • we need to be able to get the content of our promise without async/await since that's not allowed in react outside of "Framework Land"
  • upon re-render, fetch will actually create a new promise, which will be thrown again, and we'll be looping forever...

The solution to both of these issues is to find a way to store the promise outside of the Suspense boundary (and in most likelihood, outside of react entirely).

Solution

Obtain promise status without async

First, let's write a wrapper around any promise that will allow us to get either its status (pending, resolved, rejected) or its resolved data.

const promises = new WeakMap()
function wrapPromise(promise) {
  const meta = promises.get(promise) || {}

  // for any new promise
  if (!meta.status) {
    meta.status = 'pending' // set it as pending
    promise.then((data) => { // when resolved, store the data
      meta.status = 'resolved'
      meta.data = data
    })
    promise.catch((error) => { // when rejected store the error
      meta.status = 'rejected'
      meta.error = error
    })
    promises.set(promise, meta)
  }

  if (meta.status === 'pending') { // if still pending, throw promise to Suspense
    throw promise
  }
  if (meta.status === 'error') { // if error, throw error to ErrorBoundary
    throw new Error(meta.error)
  }

  return meta.data // otherwise, return resolved data
}

With this function called on every render, we'll be able to get the promise's data without any async. It's then React Suspense's job to re-render when needed. That what it does.

Maintain a constant reference to Promise

Then we only need to store our promise outside of the Suspense boundary. The most simple example of this would be to declare it in the parent, but the ideal solution (to avoid creating a new promise when the parent itself re-renders) would be to store it outside of react itself.

export default function App() {

  // create a promise *outside* of the Suspense boundary
  const promise = fetch('/url').then(r => r.json())

  // Suspense will try to render its children, if rendering throws a promise, it'll try again when that promise resolves
  return (
    <Suspense fallback={<div>Loading...</div>}>
      {/* we pass the promise to our suspended ponent so it's always the same `Promise` every time it re-renders */}
      <ComponentWithLoad promise={promise} />
    </Suspense>
  )
}

function ComponentWithLoad({promise}) {
  // using the wrapper we declared above, it will
  //  - throw a Promise if it's still pending
  //  - return synchronously the result of our promise otherwise
  const data = wrapPromise(promise)

  // we now have access to our fetched data without ever using `async`
  return <p>{data}</p>
}

Some more details

  • WeakMap is pretty ideal to map between a promise and some metadata about this promise (status, returned data, ...) because as soon as the promise itself is not referenced anywhere, the metadata is made available for garbage collection
  • While a ponent is "under suspense" (meaning any ponent in the render tree from it to the next Suspense boundary throws a promise), it will be unmounted by react after each "attempt" at rendering. This means that you cannot use a useState or a useRef to hold the promise or its status.
  • unless you are writing an opinionated library (like tanstack-query for example), it's almost impossible to have a generally valid way of storing promises. It's entirely dependant on your application's behavior. It might be as simple as having a Map between endpoints and the Promise fetching that endpoint, and only grows in plexity from there with refetches, cache-control, headers, request params... This is why my example only creates a simple promise once.

Answer to the question

When using Suspense, none of the tree inside of the Suspense node will be rendered while any of it still throws a Promise. If you need to render something in the meantime, that's what the fallback prop is for.

It does require us to change the way we think about the segmentation of our ponents

  • if you want your fallback to share some of the structure / data / css with the suspended ponent
  • if you want to avoid a waterfall of loading ponents preventing a big render tree from displaying anything at all

Just add async to the ponent.

Component:

const ListProducts = async ({ data }: { data: any }) => {
  return (
    <div>
      <ul>
        {data.children.map((item: any) => (
          <li key={item.data.id} className="p-2 bg-slate-400 my-1">
            {item.data.title}
          </li>
        ))}
      </ul>
    </div>
  );
};

export default ListProducts;

Parent ponent or Page:

<Suspense
  key={searchParams.limit}
  fallback={
    <div className="flex items-center justify-center h-[50vh] bg-black text-white text-4xl rounded-2xl">
      Loading...
    </div>
      }
    >
    <ListProducts data={data} />
</Suspense>
发布评论

评论列表(0)

  1. 暂无评论