A tiny bridge between React.useSyncExternalStore and React Suspense
This library allows you to create asynchronous ref objects (AsyncMutableRefObject<T>
). Similar to the sync ref objects you'd get back from useRef<T>()
, except if the current value of the ref (ref.current
) is not set, it will throw a Promise
instead of returning nothing.
React.Suspense
works by catching promises—thrown as part of rendering a component—and waiting for them to resolve before re-rendering again. Assigning a value to ref.current
will trigger the suspense boundary to re-render. (You can read more about how Suspense works in the React docs.)
Because ref.current
will either return a value T
or throw a promise, the only thing it can return is a T
and therefore implements the MutableRefObject<T>
interface. That is,
type MutableRefObject<T> = {
current: T;
};
class AsyncMutableRefObject<T> implements MutableRefObject<T> {
}
React v18 introduces the experimental hook useSyncExternalStore
which provides a convenient way to hook into synchronous external data sources.
declare function useSyncExternalStore<Snapshot>(
subscribe: (onStoreChange: () => void) => () => void,
getSnapshot: () => Snapshot,
getServerSnapshot?: () => Snapshot
): Snapshot;
AsyncMutableRefObject<T>
makes it easy for a typed asynchronous external data source to work with React Suspense in a typed synchornous way because we can implement getSnapshot
to just always return the result of ref.current
.
Example
A sample implementation of what you might do is the following where we define a data source that implements getSnapshot
and subscribe
functions.
type Subscriber = () => void;
class AsyncDataStore<T> {
subscribers = new Set<Subscriber>();
ref = createAsyncRef<T>(() => this.notify());
getSnapshot = (): T => {
return this.ref.current;
};
subscribe = (sub: Subscriber): Subscriber => {
this.subscribers.add(sub);
return () => this.subscribers.delete(sub);
};
notify() {
this.subscribers.forEach((sub) => sub());
}
doSomething() {
fetch()
.then((res) => res.json())
.then((data) => {
ref.current = data;
});
}
}
As long as getSnapshot()
is called from within the React-render cycle, the Promise
it (might) throw will be caught by Suspense.
const store = new AsyncDataStore<User>();
const user = React.useSyncExternalStore(store.subscribe, store.getSnapshot);
store.doSomething();
const user = React.useSyncExternalStore(store.subscribe, store.getSnapshot);
Use
async-ref
exports a single function createAsyncRef<T>()
which accepts an optional notifier function and returns an AsyncMutableRefObject<T>
.
declare function createAsyncRef<T>(notifier?: () => void): AsyncMutableRefObject<T>;
import { createAsyncRef } from "async-ref";
const ref = createAsyncRef<User>();
const currentValue: User = ref.current;
ref.current = { id: "12345", name: "Gabe" };
const currentValue: User = ref.current;
Just like a MutableRefObject
, the current value can be set any number of times.
import { createAsyncRef } from "async-ref";
const ref = createAsyncRef<number>();
ref.current = 12;
ref.current = 400;
const currentValue = ref.current;
Alternatively, AsyncMutableRefObject<T>
exposes resolve
/reject
functions for a more familiar Promise-type feel.
import { createAsyncRef } from "async-ref";
const ref = createAsyncRef<number>();
ref.reject(new Error("uh oh!"));
ref.current;
If you provide a notifier function, it will be called every time the state of the ref changes.
import { createAsyncRef } from "async-ref";
const listener = jest.fn();
const ref = createAsyncRef<number>(listener);
ref.current = 12;
ref.current = 400;
expect(listener).toHaveBeenCallTimes(2);
If you want to prevent the ref from changing its state, you can freeze it.
const ref = createAsyncRef<number>(listener);
ref.current = 12;
ref.freeze();
ref.current = 400;
expect(ref.current).toEqual(12);
Safely getting the value without Suspense
AsyncMutableRefObject<T>
also implements PromiseLike<T>
which means that you can dereference the current value by awaiting on it. (If a current value is already set, it will immediately resolve.) This is safer than calling ref.current
because it will wait for a current value to be set before resolving the promise, but of course does not work inside of a React component because it is asynchronous.
import { createAsyncRef } from "async-ref";
const ref = createAsyncRef<User>(listener);
const user = await ref;
Installing
yarn add async-ref