2

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.

Doug
  • 1,517
  • 3
  • 18
  • 40
  • "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." has all the hallmarks of an [XY problem](https://xyproblem.info/). React refs can be mutated at any time, so they are, or should be, an obviously inherently terrible React hook dependency because they are not coupled to the React component lifecycle at all. Try to take a step back and re-asses and explain what you are really trying to accomplish. This may very well mean including ***all*** the relevant code you are working with in a [mcve]. – Drew Reese Aug 31 '22 at 08:56

3 Answers3

3

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.

Ivan Shumilin
  • 1,743
  • 3
  • 13
  • 18
  • Wow, amazing. I completely missed the effect that the `setResults` re-render has on the first effect hook. So it overrides the first effect hook because the value of `selectedResultType` hasn't changed yet, which means the second hook isn't called until later, which then ends up stacking twice with the re-render from `setResults`. Thank you so much for unraveling the chronology of the renders. Your code sample really helps visualize the component life cycle. – Doug Aug 31 '22 at 14:04
0

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.

Youssouf Oumar
  • 29,373
  • 11
  • 46
  • 65
0

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