问题
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:
useGet
is called for the first time -- it returnsundefined
because theuseEffect
hasn't fired yet and the data hasn't been fetched yetuseEffect
fires, the data is fetched, and the component re-renders with fetched data- If the
userId
changes thenuseGet
is called again --useEffect
will fire (becausegetData
has changed), but it hasn't fired yet, so for nowuseGet
returns stale data (i.e. neither new data norundefined
) -- so the component re-renders with stale data - 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:
useGet
is called for the first time -- it returnsundefined
because theuseEffect
hasn't fired yet and the data hasn't been fetched yetUser starting with userId=5 useGet starting useGet returning User rendering data 'undefined'
useEffect
fires, the data is fetched, and the component re-renders with fetched datauseEffect starting mockServer getting /users/5/unknown User starting with userId=5 useGet starting useGet returning User rendering data for userId=5
If the
userId
changes thenuseGet
is called again --useEffect
will fire (becausegetData
has changed), but it hasn't fired yet, so for nowuseGet
returns stale data (i.e. neither new data norundefined
) -- so the component re-renders with stale dataUser starting with userId=1 useGet starting useGet returning User rendering data for userId=5 userId mismatch -- userId specifies 1 whereas data is for 5
Soon,
useEffect
fires, and the component re-renders with new datauseEffect 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