Let's Play with Suspense
— Comments — code, tips, tricks, react — 2 min read
Four years ago, Dan Abramov gave a preview of React’s future and toward the end, demoed Suspense with data loading… it blew. my. mind. You can see that video here:
Around that same time, I had also seen posts on Twitter from React core team members with this crazy idea of “throwing” promises to simulate synchronous execution with asynchronous operations… I thought it was clever but moved on.
Fast forward to mid-2019, React had already released a version of Suspense (just to be used with React.lazy()
) and I was deliberating with some colleagues about how we wished suspense for data loading was available because it would simplify the task in front of us… and practically mid-conversation, something clicked in my head!
I wondered: Could I make it work with the existing Suspense component!?
With this new curiosity, and desire to use this magic… I went and dug up that video and reverse engineered Dan’s example. It worked! I didn’t even need to opt into concurrent mode! So, I made it into a hook😜
Let’s break it down
Here is the hook simplified to its bare essence:
function useAsyncValue(factory, key) { let reader = CACHED_TASKS[key]; if (!reader) { reader = CACHED_TASKS[key] = toReader(factory()); } return reader.read();}
Pass in a callback that will make a new promise that will in turn fulfill/reject when the task is complete. Since the function will likely be inline, and not unique, we have to identify it so we can track it. To make it simple for now, I chose to pass a key as its identifier.
Next, we invoke the factory and wrap it with toReader
. Store the result and return with the call to the read
method... and done?!
Well, yes, but not quite.
Referring to the example in the video, to "suspend" a render while data loads, we need to throw a promise. Not just any promise, the same unique promise for each operation across renders. To accomplish this the factory's returned promise is wrapped and tracked in the closure with only one external method, named read
. I called this construct the "reader". The reader is then cached in a map based on the key that was given along with the factory. Once the promise resolves, react will resume rendering and our hook function will be called again, free to return our result, or throw our error, synchronously.
function toReader(promise) { let result; let status = 'pending';
const on = (state) => (_) => { result = _; status = state; };
const suspender = promise.then(on('success'), on('error'));
return { read() { if (status === 'pending') { throw suspender; }
if (status === 'error') { throw result; }
return result; }, };}
The version in this gist adds some extras like reloading and cleanup. I’ve used this hook in production code since 2020… on React 17+ code. It works today without any special builds or flags. 😊