3

I'd like to perform a side-effect when some data changes, e.g.

const useSearchResults = () => {
  const location = useLocation();
  const [data, setData] = useState();
  useEffect(() => {
    Api.search(location.query.q).then(data => setData(data));
  }, [location.query.q]);

  // Compute some derived data from `data`.
  const searchResults =
    data &&
    data.map(item => ({
      id: item.id,
      name: `${item.firstName} ${item.lastName}`
    }));

  return searchResults;
};

const Component = () => {
  const searchResults = useSearchResults();
  useEffect(() => {
    alert('Search results have changed'); // Side-effect
  }, [searchResults]);
  return <pre>{JSON.stringify(searchResults)}</pre>;
};

If something causes Component to re-render, the alert will fire even if the searchResults haven't changed because we map over the underlying stable data in useSearchResults creating a new instance on every render.

My initial approach would be to use useMemo:

  // Stabilise `searchResults`'s identity with useMemo.
  const searchResults = useMemo(
    () =>
      data &&
      data.map(item => ({
        id: item.id,
        name: `${item.firstName} ${item.lastName}`
      })),
    [data]
  );

However useMemo has no semantic guarantee so it's (theoretically) only good for performance optimisations.

Does React offer a straight forward solution to this (common?) problem?

Richard Scarrott
  • 6,638
  • 1
  • 35
  • 46
  • 1
    In the past I've used reselect to derive data from a redux store which has solved this issue but that get's a little complex when considering it only memoizes 1 value at a time so if you have multiple instances of a component you need to create multiple instances of a selector which is fiddly. – Richard Scarrott Feb 05 '21 at 14:05
  • Doing some research I found some detailed discussion here https://github.com/facebook/react/issues/15278 which lead me to https://github.com/alexreardon/use-memo-one which seems like it'd solve the issue however given that package isn't hugely popular I'm confused as to why this is a more widely documented / solved issue – Richard Scarrott Feb 05 '21 at 15:00

1 Answers1

0

If it's very important that the side effect doesn't re-run, you can base your comparison on a deep equality check. We want to see that the search results are actually different results and not just a new array with the same results.

There are various implementations of a usePrevious hook floating around that you can use, but basically it's just a ref. Save the previous version of the search results to a ref. When your effect runs, see if the current results are different than the previous results. If they are, do your side effect and update the previous to the current.

const Component = () => {
  const searchResults = useSearchResults();

  // create ref for previous
  const comparisonRef = useRef('');

  useEffect(() => {
    // stringify to compare
    if ( JSON.stringify(searchResults) !== JSON.stringify(comparisonRef.current) ) {
       // run side effect only if true
       alert('Search results have changed');
       // update the ref for the next check
       comparisonRef.current = searchResults;
    }
  }, [searchResults, comparisonRef]);

  return <pre>{JSON.stringify(searchResults)}</pre>;
};
Linda Paiste
  • 38,446
  • 6
  • 64
  • 102
  • Yeah it is critical the side-effect doesn't re-run as it's an analytics event. I wanted to avoid deep equality checks as the API response can be quite large so it's not going to be great for performance (granted it doesn't re-render often right now, but who knows, somebody might add useTime or something similar which would likely require refactoring this change detection). So far I think https://github.com/alexreardon/use-memo-one is the most sane solution. – Richard Scarrott Feb 06 '21 at 10:06