badgateway/react-ketting

useResource may go into an infinite render loop if given a new promise on every render.

evert opened this issue · 1 comments

evert commented

useResource can be called in 3 main ways:

useResource('/foo'); // with a string
useResource(someResource); // Passing a Resource object
useResource((async() => someResource)()); // Passing a Promise<Resource>

One of the advantages of passing promises, is that in theory it lets you do something like this:

const { data, error, loading } = useResource(
  myBookResource.follow('author')
);

Unfortunately, useResource needs to have a way to detect that a different argument was passed for different renders, in case there are subsequent re-renders pointing to different endpoints:

const [url, setUrl] = useState('/video/1');

useResource('/video/1');

useEffect(() => {
  setUrl('/video/2');
}, []);

This works fine for the string and Resource case, but it doesn't behave well for the Promise<Resource> case.

As a result, doing:

const { data, error, loading } = useResource(
  myBookResource.follow('author')
);

Will cause useResource to re-render continuously. The core reason is that myBookResource.follow('author') returns a new promise every time it's called.

I currently have no idea yet on how we can solve this, but here's a few half-baked solutions:

  1. If the argument to useResource changed, maybe we don't go back to a loading state. We only re-render after the promise resolved. This might be surprising because it would mean that users will see the previous resource state until the new one has loaded.
  2. Let users pass their own dependency array that we can use. Not very elegant but it could work.

I wish I had a better answer to this, but right now I don't.

Workaround

The best workaround currently is to use 2 components instead of 1.

Broken Example:

function MyComponent() {
  const {loading, error, data} = useResource(blogResource.follow('author'));

  if (loading) {
    return <div>loading</div>;
  }
  
  return <div>{data.name}</div>;

Workaround

function MyComponent() {
  return <ChildComponent resource={blogResource.follow('author')}/>;
}

type props: {
  resource: Promise<Resource>;
}

function ChildComponent(props: Props) {

    const {loading, error, data} = useResource(props.resource);

    if (loading) {
      return <div>loading</div>;
    }

    return <div>{data.name}</div>;
}
evert commented

Will be fixed with #73