Related useEffects where the first one updates a dependency of the second are causing weird behaviour

Viewed 139

Edited code block to include console logs from accepted answer.

I have a search bar component that I have completely stripped down to its simplest possible form:

import React, { useEffect, useRef, useState } from "react";
import axios from "axios";

const resultTypes = ["categories", "submissions", "users"];

const SearchBar = () => {
  const [results, setResults] = useState([]);
  const [selectedResultType, setSelectedResultType] = useState(resultTypes[0]);

  console.log("render --------");

  console.log("selectedResultType: " + selectedResultType);

  const selectedResultTypeRef = useRef(null);
  console.log(
    "selectedResultTypeRef.current: " + selectedResultTypeRef.current
  );

  const fetchResults = async () => {
    console.log("fetching...");
    const response = await axios.get(
      "https://jsonplaceholder.typicode.com/todos/3"
    );
    setResults(response.data);
    console.log("setResults: ", response.data);
  };

  useEffect(() => {
    console.log("effect 1 ---------");
    selectedResultTypeRef.current = selectedResultType;
    console.log(
      "selectedResultTypeRef.current: " + selectedResultTypeRef.current
    );
  }, [selectedResultType]);

  useEffect(() => {
    console.log("effect 2 ---------");
    // No need to debounce fetch when changing result type
    fetchResults();
  }, [selectedResultTypeRef.current]);

  return (
    <div className="SearchBar__container">
      <div className="SearchBarResults__types">
        {resultTypes.map((resultType) => (
          <button
            key={resultType}
            onClick={() => {
              console.log("click " + resultType + " -------");
              setSelectedResultType(resultType);
            }}
          >
            {resultType}
          </button>
        ))}
      </div>
    </div>
  );
};

export default SearchBar;

When I click between my 3 buttons (categories, submissions, and users), I expect the selectedResultType to change, which will then change selectedResultTypeRef.current so I can pass it into a memoized, debounced function. I removed all of this because it's not relevant, but that's the reason I need to use refs instead of just component state.

Here's my problem: When I call the async function and wait for a response to set results, it seems that selectedResultTypeRef.current only changes (or is recognized as a change) every other click. I've tried different combinations of clicking through the 3 buttons, and it's always every other click that triggers the hook that calls fetchResults.

Here's the console output using the above code: enter image description here

Oddly, it fetches twice after missing a fetch for the previous ref change. I really don't know why this is. What's also curious is that when I replace the setResults line with just a simple console.log(response.data), it never misses a hook. Here's the console output from that: enter image description here

My suspicion is that the setResults update somehow triggers a re-render of the dom (even though I'm not using the results state anywhere in this simplified version), and it interferes with selectedResultTypeRef being recognized as an update that gets captured by the useEffect hook. I don't know why it's every other update, but I've at least verified its consistent pattern.

Other than that, I'm completely at a loss. I don't know what else to try.

3 Answers

Here I'm answering to the question "Why is this happening?" and not to the "How to build a search bar with debouncing?" because the details was intentionally omitted.

It is important to remember that:

  • Calling the setter from useState causes a rerender if the data has changed. Despite the fact that the data is not used.
  • The effect checks its dependency list during the render, when useEffect is called.
  • Those effects whose dependencies has changed are executed after the render phase.

Now let's say everything is loaded. selectedResultType === "categories" and selectedResultTypeRef.current === "categories".

  1. Click on submissions button. setSelectedResultType is called and rerender started.
  2. Checking the dependencies: selectedResultType has changed to submissions so effect 1 will be called after the render. selectedResultTypeRef.current has not changed yet, it is still "categories", so effect 2 will not be executed. That's why there is no refetch.
  3. effect 1 is executed. Now selectedResultType === "submissions" and selectedResultTypeRef.current === "submissions".
  4. Click on users button -> rerender.
  5. Checking the dependencies: effect 1 will be called. selectedResultTypeRef.current has changed from "categories" to "submissions" so effect 2 will also be executed.
  6. effect 1 is executed. Now selectedResultType === "users" and selectedResultTypeRef.current === "users".
  7. effect 2 is executed -> refetch -> setResults -> rerender.
  8. Checking the dependencies: effect 1 will not be called. But effect 2 will be called because selectedResultTypeRef.current has changed from "submissions" to "users".
  9. effect 2 is executed -> refetch -> setResults -> rerender.
  10. Checking the dependencies: no changes.

CodeSandbox

So it is understandable what is going on, but hard to follow.

The conclusion is very simple — trust the linter. It will save you a headache.

Mutable values like 'selectedResultTypeRef.current' aren't valid dependencies because mutating them doesn't re-render the component.

For me your problem is all about when React checks the dependency array of an useEffect, to know if this selectedResultTypeRef.current = selectedResultType in the first useEffect will tell the second one which has selectedResultTypeRef.current as dependency to call its callback.

I searched, one article led me to another until I found A Complete Guide to useEffect by by Dan Abramov. After reading it, I understood that on a render phase, React doesn't know what happens in the callback, it just check the dependency array. A quote talking about an useEffect that has name as dependency:

It’s like if we told React: “Hey, I know you can’t see inside this function, but I promise it only uses name and nothing else from the render scope.”

Knowing that, when you change selectedResultType, React will trigger a re-render. And while re-rendering, it would read top to bottom the component, sees your tow below useEffect:

 useEffect(() => {
  console.log('selectedResultType', selectedResultType);
  selectedResultTypeRef.current = selectedResultType
}, [selectedResultType])

useEffect(() => {
  console.log('selectedResultTypeRef.current', selectedResultTypeRef.current);
  fetchResults()
}, [selectedResultTypeRef.current])

For the first one React sees selectedResultType in the dependency array and it has changed (the reason we are in this render phase), so React notes that it will run the callback after the DOM is updated.

Then React sees the second one with selectedResultTypeRef.current as dependency, and because the callback of the first one did not run yet, selectedResultTypeRef.current is the same as the previous one, so it ignores it.

I dont think i have the answer to your problem. But I can shine a light.

a react useRef is a mutable container, this means that it should not be used as a dependency in a useEffect. I believe this would be where the issue lies, it is not a reliable dependency and should never be used like that, this is not the expected use case within reactjs.

for a better explanation, please see this link https://stackoverflow.com/a/60476525/4224964

Related