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?

2
  • 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. Commented Feb 5, 2021 at 14:05
  • Doing some research I found some detailed discussion here github.com/facebook/react/issues/15278 which lead me to 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 🤔 Commented Feb 5, 2021 at 15:00

1 Answer 1

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>;
};
Sign up to request clarification or add additional context in comments.

1 Comment

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 github.com/alexreardon/use-memo-one is the most sane solution.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.