Avoid old data when using useEffect to fetch data

久未见 提交于 2021-02-07 03:32:39

问题


My problem is, when a custom hook uses useEffect with useState (e.g. to fetch data), the custom hook returns stale data (from the state), after dependencies change but before useEffect is fired.

Can you suggest a right/idiomatic way to resolve that?


I'm using the React documentation and these articles to guide me:

  • A Complete Guide to useEffect
  • How to fetch data with React Hooks?

I defined a function, which uses useEffect and which is meant to wrap the fetching of data -- the source code is TypeScript not JavaScript but that doesn't matter -- I think this is "by the book":

function useGet<TData>(getData: () => Promise<TData>): TData | undefined {

  const [data, setData] = React.useState<TData | undefined>(undefined);

  React.useEffect(() => {
    getData()
      .then((fetched) => setData(fetched));
  }, [getData]);

  // (TODO later -- handle abort of data fetching)

  return data;
}

The App routes to various components depending on the URL -- for example here is the component which fetches and displays user profile data (when given a URL like https://stackoverflow.com/users/49942/chrisw where 49942 is the "userId"):

export const User: React.FunctionComponent<RouteComponentProps> =
  (props: RouteComponentProps) => {

  // parse the URL to get the userId of the User profile to be displayed
  const userId = splitPathUser(props.location.pathname);

  // to fetch the data, call the IO.getUser function, passing userId as a parameter
  const getUser = React.useCallback(() => IO.getUser(userId), [userId]);

  // invoke useEffect, passing getUser to fetch the data
  const data: I.User | undefined = useGet(getUser);

  // use the data to render
  if (!data) {
    // TODO render a place-holder because the data hasn't been fetched yet
  } else {
    // TODO render using the data
  }
}

I think that's standard -- if the component is called with a different userId, then the useCallback will return a different value, and therefore the useEffect will fire again because getData is declared in its dependency array.

However, what I see is:

  1. useGet is called for the first time -- it returns undefined because the useEffect hasn't fired yet and the data hasn't been fetched yet
  2. useEffect fires, the data is fetched, and the component re-renders with fetched data
  3. If the userId changes then useGet is called again -- useEffect will fire (because getData has changed), but it hasn't fired yet, so for now useGet returns stale data (i.e. neither new data nor undefined) -- so the component re-renders with stale data
  4. Soon, useEffect fires, and the component re-renders with new data

Using stale data in step #3 is undesirable.

How can I avoid that? Is there a normal/idiomatic way?

I don't see a fix for this in the articles I referenced above.

A possible fix (i.e. this seems to work) is to rewrite the useGet function as follows:

function useGet2<TData, TParam>(getData: () => Promise<TData>, param: TParam): TData | undefined {

  const [prev, setPrev] = React.useState<TParam | undefined>(undefined);
  const [data, setData] = React.useState<TData | undefined>(undefined);

  React.useEffect(() => {
    getData()
      .then((fetched) => setData(fetched));
  }, [getData, param]);

  if (prev !== param) {
    // userId parameter changed -- avoid returning stale data
    setPrev(param);
    setData(undefined);
    return undefined;
  }

  return data;
}

... which obviously the component calls like this:

  // invoke useEffect, passing getUser to fetch the data
  const data: I.User | undefined = useGet2(getUser, userId);

... but it worries me that I don't see this in the published articles -- is it necessary and the best way to do that?

Also if I'm going to explicitly return undefined like that, is there a neat way to test whether useEffect is going to fire, i.e. to test whether its dependency array has changed? Must I duplicate what useEffect does, by explicitly storing the old userId and/or getData function as a state variable (as shown in the useGet2 function above)?


To clarify what's happening and to show why adding a "cleanup hook" is ineffective, I added a cleanup hook to useEffect plus console.log messages, so the source code is as follows.

function useGet<TData>(getData: () => Promise<TData>): TData | undefined {

  const [data, setData] = React.useState<TData | undefined>(undefined);

  console.log(`useGet starting`);

  React.useEffect(() => {
    console.log(`useEffect starting`);
    let ignore = false;
    setData(undefined);
    getData()
      .then((fetched) => {
        if (!ignore)
          setData(fetched)
      });
    return () => {
      console.log("useEffect cleanup running");
      ignore = true;
    }
  }, [getData, param]);

  console.log(`useGet returning`);
  return data;
}

export const User: React.FunctionComponent<RouteComponentProps> =
  (props: RouteComponentProps) => {

  // parse the URL to get the userId of the User profile to be displayed
  const userId = splitPathUser(props.location.pathname);

  // to fetch the data, call the IO.getUser function, passing userId as a parameter
  const getUser = React.useCallback(() => IO.getUser(userId), [userId]);

  console.log(`User starting with userId=${userId}`);

  // invoke useEffect, passing getUser to fetch the data
  const data: I.User | undefined = useGet(getUser);

  console.log(`User rendering data ${!data ? "'undefined'" : `for userId=${data.summary.idName.id}`}`);
  if (data && (data.summary.idName.id !== userId)) {
    console.log(`userId mismatch -- userId specifies ${userId} whereas data is for ${data.summary.idName.id}`);
    data = undefined;
  }

  // use the data to render
  if (!data) {
    // TODO render a place-holder because the data hasn't been fetched yet
  } else {
    // TODO render using the data
  }
}

And here are the run-time log messages associated with each of the four steps I outlined above:

  1. useGet is called for the first time -- it returns undefined because the useEffect hasn't fired yet and the data hasn't been fetched yet

    User starting with userId=5
    useGet starting
    useGet returning
    User rendering data 'undefined'
    
  2. useEffect fires, the data is fetched, and the component re-renders with fetched data

    useEffect starting
    mockServer getting /users/5/unknown
    User starting with userId=5
    useGet starting
    useGet returning
    User rendering data for userId=5
    
  3. If the userId changes then useGet is called again -- useEffect will fire (because getData has changed), but it hasn't fired yet, so for now useGet returns stale data (i.e. neither new data nor undefined) -- so the component re-renders with stale data

    User starting with userId=1
    useGet starting
    useGet returning
    User rendering data for userId=5
    userId mismatch -- userId specifies 1 whereas data is for 5
    
  4. Soon, useEffect fires, and the component re-renders with new data

    useEffect cleanup running
    useEffect starting
    UserProfile starting with userId=1
    useGet starting
    useGet returning
    User rendering data 'undefined'
    mockServer getting /users/1/unknown
    User starting with userId=1
    useGet starting
    useGet returning
    User rendering data for userId=1
    

In summary the cleanup does run as part of step 4 (probably when the 2nd useEffect is scheduled), but that's still too late to prevent the returning of stale data at the end of step 3, after the userId changes and before the second useEffect is scheduled.


回答1:


In a reply on Twitter, @dan_abramov wrote that my useGet2 solution is more-or-less canonical:

If you do setState inside of render [and outside of useEffect] to get rid of stale state, it shouldn't ever produce a user-observable intermediate render. It will schedule another re-render synchronously. So your solution should be sufficient.

It's the idiomatic solution for derived state, and in your example state is derived from ID.

How do I implement getDerivedStateFromProps?

(In longer term there will be a different solution for data fetching altogether that doesn’t involve effects or setting state. But I'm describing what we have today.)

The article referenced from that link -- You Probably Don't Need Derived State -- explains what the root cause of the problem is.

It says that the problem is, expecting the "controlled" state passed-in to User (i.e. the userId) to match the "uncontrolled" internal state (i.e. the data returned by the effect).

The better thing to do is to depend on one or the other but not mix them.

So I suppose I should return a userId inside and/or with the data.




回答2:


You should initialize the data each time the useEffect is called inside useGet:

function useGet<TData>(getData: () => Promise<TData>): TData | undefined {

  const [data, setData] = React.useState<TData | undefined>(undefined);

  React.useEffect(() => {

    setData(undefinded) // initializing data to undefined

    getData()
      .then((fetched) => setData(fetched));
  }, [getData]);

  // (TODO later -- handle abort of data fetching)

  return data;
}


来源:https://stackoverflow.com/questions/56096560/avoid-old-data-when-using-useeffect-to-fetch-data

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!