skip to Main Content

Think of the following React component:

const [page, setPage] = useState(1)
const [search, setSearch] = useState("")

const [data, setData] = useState([])

const fetchData = useCallback(() => {
    get({page: page, search: search}.then(setData)
}, [page, search])

useEffect(() => {
    fetchData()
}, [page]) // <----- here I got the react-hooks/exhaustive-deps lint error

// if I add `fetchData` above, `fetchData` will be called whenever `search` change,
// but I only want to fetch data when `page` change.

return <div>
    <Input value={search} setValue={setSearch}/>

    <Button onClick={() => fetchData()}>Search</Button>

    {data.maps(v => <div>{v}</div>}

    <Select value={page} setValue={setPage}/>
</div>

The goal is really simple:

  • fetch data when page change;
  • do not fetch data when search change;
  • fetch data when the button is clicked;

I really cannot think of any other way to bypass the eslint rule. Thanks for any help.

3

Answers


  1. This is one of the most common objections that I can think of to any react linting rule, and it’s somewhat warranted- our mental model for how dependencies work is that they are all ‘reactive’, and that when they change, we necessarily want to re-run all of the logic inside of the effect.

    In this case, the search dependency falls under the second bullet point under ‘Removing Unnecessary Dependencies’ in the React docs, where we just want to read its latest value:

    You might want to only read the latest value of some dependency
    instead of “reacting” to its changes.

    You could read the current reference for page, and only run your effect when the page changes.

    const fetchData = useCallback(() => {
      get({
        page: page,
        search: search
      }).then(setData)
    }, [page, search])
      
    const prevPageRef = useRef();
    
    useEffect(() => {
    //this logic can be extracted into a reusable hook
      if (prevPageRef.current !== page) {
        fetchData({page, search});
        prevPageRef.current=page;
      }
    }, [page, search])

    However, this leaves us with a very odd user experience- you said, "do not fetch data when search changes". Does this mean that we only ever return search results, for example a text string typed into an input field, after the user has clicked to change the page?

    This implementation seems counter-intuitive to how a search field should work- i.e., you receive results back for the search query immediately, not after taking some subsequent action (changing the page).

    I would imagine that, in practice, there actually is a reactive relationship between search and getData(). It’s hard to imagine decoupling the method from the search dependency.

    Login or Signup to reply.
  2. In general disabling exhaustive dependencies using eslint-disable is not a great idea longterm for the health of a useEffect. Especially if more methods and data are tacked on.

    For this example usecase, everytime the page changes there is an interaction between the useEffect and the callback.

    The callback is the only real necessary dependency to trigger the hook since it already updates when page and search change.

    Ignoring this interaction could lead to memory leaks and other weird async setState issues.

    Example
    What happens if the callback is delayed but the page changes?

    Technically this is a memory leak (in the example above). If you fetch multiple pages it will fire setState Asynchronously, per call that resolves instead of making sure it’s not unmounted or if it’s on the correct page.

    Recommendation:

    • If the callback is already fetching for that page. Skip the call.
    • if the search term changes but the page has not, skip the call. Add a useRef and track the current page data to make sure the initial call has completed.
    • If the component is unmounted skip the call.
    • If the data needs to be refetched. Have a way to cancel the setState or promise so it does not interfere with other calls.
    • consider rewriting the callback into two parts. A fetch and a cancellable set state.
    • the useEffect could actively cancel the second part if there is a page change.
    Login or Signup to reply.
  3. search is the correct dep, and you should keep it there. However, if you only mean for the "actual" value for the data request to change only after the user accepts it with the Search button, simply have a separate state atom for it:

      const [search, setSearch] = useState("");
      const [actualSearch, setActualSearch] = useState("");
      // ... and set up the fetch to use actualSearch ...
      <Button onClick={() => setActualSearch(search)}>Search</Button>
    

    However, you probably shouldn’t be doing data fetching with useEffect by hand at all, since as it is, you’re missing e.g. error states, loading states, etc.

    The lightweight option is swr, and you’d get something like:

    function Component() {
      const [page, setPage] = useState(1);
      const [search, setSearch] = useState("");
      const [actualSearch, setActualSearch] = useState("");
      const dataSWR = useSWR(["search", page, actualSearch], ([_, page, search]) =>
        get({ page, search }),
      );
      if (!dataSWR.data) {
        if (dataSWR.error) {
          return <div>Error!</div>;
        }
        return <div>Loading...</div>;
      }
    
      return (
        <div>
          <Input value={search} setValue={setSearch} />
          <Button onClick={() => setActualSearch(search)}>Search</Button>
          {dataSWR.data.map((v) => (
            <div>{v}</div>
          ))}
          <Select value={page} setValue={setPage} />
        </div>
      );
    }
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search