async-ref
TypeScript icon, indicating that this package has built-in type declarations

0.1.6 • Public • Published

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 User

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;
// => 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;
// => 400

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;
// => 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

Package Sidebar

Install

npm i async-ref

Weekly Downloads

3,269

Version

0.1.6

License

MIT

Unpacked Size

28.1 kB

Total Files

9

Last publish

Collaborators

  • garbles