badgateway/react-ketting

Re-render when one item in collection is updated.

gwen1230 opened this issue · 6 comments

Hello !
I use the hook useCollection with an Hypermedia API (HAL) and i have problems with ketting cache.

I have an endpoint who returns a JSON like

{
  _embedded: {
    items: [
      {{code: 'CODE_1}, links: {self: XXX}},
      {{code: 'CODE_2}, links: {self: XXX}}
    ]
  _links: {
    self: 'api/test?from=XXX&to=XXX'
  }
}

And i use useCollection like this :

  const [state, setState] = useState([]);
  const { items } = useCollection(
    res.follow('collection', {
      from: currentMonth.toISOString(),
      to: currentMonth.toISOString(),
    }),
    { rel: 'items', refreshOnStale: true }
  );

  useEffect(() => {
    async function populateData() {
      setState(await Promise.all(items.map((t) => t.get())));
    }
    populateData();
  }, [items]);

  return state.map((s) => <>s.code</>)

When I perform certain actions on another component, ketting cache of one item in the previous list is updated, but the component that call the useCollection doesn't refresh (and the s.code remains the same).
Did I misunderstand something?

I have found a solution :

export function useResourceList<T>(state: ResourceLike<unknown>, rel = 'items') {
  const [internalState, setInternalState] = useState<State<T>[]>([]);
  const [loading, setLoading] = useState<boolean>(false);
  const { items, loading: loadingCollection } = useCollection<T>(state, {
    rel,
  });

  async function populateData() {
    setLoading(true);
    items.forEach((item, idx) => {
      item.on('update', (state: State<T>) => {
        const newState = [...internalState];
        newState.splice(idx, 1, state);
        setInternalState(newState);
      });
    });
    const newState = await Promise.all(items.map((t) => t.get()));
    setInternalState(newState);
    setLoading(false);
  }
  useEffect(() => {
    populateData();
  }, [items]);
  return { state: internalState, loading: loadingCollection || loading };
}

Any remarks ?

It doesn't work as expected, internalState in the 'on' hook is always empty

evert commented

Hi Gwen,

The normal way to solve this that you use useCollection, and for each item (in items) that you receive you create a new component that uses the useResource hook.

So to rewrite your original example:

function MyCollection() {

  const { items, loading } = useCollection(
    res.follow('collection', {
      from: currentMonth.toISOString(),
      to: currentMonth.toISOString(),
    }),
    // Note that 'items' instead of 'item' is less common, so you might prefer to
    // rename this rel.
    { rel: 'items', refreshOnStale: true }
  );

  if (loading) return 'Loading...';

  return items.map(item => <MyItem key={item.url} resource={item});

}

function MyItem(props: {resource: Resource}) {

  const { data, loading } = useResource(resource);
  if (loading) return 'Loading...';
  return <>data.code</>;
}

The reason the event for changing resources is not changing the top-level, is because changes in the collection do not extend to 'embedded items'. Changes in the event only really apply to propertes on the collection itself, or membership changes (adding or removing item from collection), but not the state of the members themselves.

I do think having a hook like useResourceList would be useful in the future though, so definitely open to considering that a feature request.

Yes I thought of this solution but it may not apply in some cases.
For example, I have pages that must display the sum of a field present in each element of a list...
When one element changes, the sum must be refreshed.

I also use react-table, and similarly, if an item in the list changes, I need to be able to refresh it.

I have improved the proposed code in this way:

export function useResourceList<T>(state: ResourceLike<unknown>, rel = 'items') {
  const [internalState, setInternalState] = useState<State<T>[]>([]);
  const [loading, setLoading] = useState<boolean>(false);
  const { items, loading: loadingCollection } = useCollection<T>(state, {
    rel,
  });

  async function populateData() {
    setLoading(true);
    const newState = await Promise.all(items.map((t) => t.get()));
    setInternalState(newState);
    setLoading(false);
  }

  useEffect(() => {
    items.forEach((item, idx) => {
      item.on('update', (state: State<T>) => {
        const newState = [...internalState];
        newState.splice(idx, 1, state);
        setInternalState(newState);
      });
    });
  }, [internalState]);

  useEffect(() => {
    if (!loadingCollection) {
      populateData();
    }
  }, [items, loadingCollection]);
  return { state: internalState, loading: loadingCollection || loading };
}
evert commented

When one element changes, the sum must be refreshed.

That does make sense. The server-side way to handle this, is if you did a PUT request on any of these, and the server returns a link header (must be a header) in response:

Link: </collection>; rel="invalidates"

This basically lets a server say: "We completed the PUT request, the client should also be aware that the cache for the parent collection is also stale.

If the server does this, Ketting will fire all the right events and re-renders.

Would that solve your problem?

I have a similar mechanism to invalidate the cache but that's not what I want.
If I modify an element in a list of 10,000 elements, I don't want to refresh all the elements...

Having failed to get by on this case and on other cases with react-ketting, I used ketting by developing my own hooks to chain the calls...