async-ref v0.1.6
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) => {
// setting the current value will notify all subscribers.
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);
// => throws Promise from the ref,
// which is caught by a Suspense boundary
// that waits for it to resolve.
// ...
store.doSomething();
// => after some time, the current value is set.
// ...
const user = React.useSyncExternalStore(store.subscribe, store.getSnapshot);
// => returns a UserUse
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;
// => throws a Promise
ref.current = { id: "12345", name: "Gabe" /* etc */ };
const currentValue: User = ref.current;
// => returns { id: '12345', name: 'Gabe', /* etc */ }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;
// => 400Alternatively, 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;
// => throws an Error("uh oh!")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