react-ketting icon indicating copy to clipboard operation
react-ketting copied to clipboard

Re-render when one item in collection is updated.

Open gwen1230 opened this issue 3 years ago • 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?

gwen1230 avatar Dec 22 '22 15:12 gwen1230

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 ?

gwen1230 avatar Dec 22 '22 17:12 gwen1230

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

gwen1230 avatar Dec 23 '22 10:12 gwen1230

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.

evert avatar Dec 24 '22 22:12 evert

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 };
}

gwen1230 avatar Jan 03 '23 08:01 gwen1230

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?

evert avatar Jan 04 '23 20:01 evert

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...

gwen1230 avatar Jan 05 '23 07:01 gwen1230