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:
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:
useGetis called for the first time -- it returnsundefinedbecause theuseEffecthasn't fired yet and the data hasn't been fetched yetuseEffectfires, the data is fetched, and the component re-renders with fetched data- If the
userIdchanges thenuseGetis called again --useEffectwill fire (becausegetDatahas changed), but it hasn't fired yet, so for nowuseGetreturns stale data (i.e. neither new data norundefined) -- so the component re-renders with stale data - Soon,
useEffectfires, 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:
useGetis called for the first time -- it returnsundefinedbecause theuseEffecthasn't fired yet and the data hasn't been fetched yetUser starting with userId=5 useGet starting useGet returning User rendering data 'undefined'useEffectfires, 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=5If the
userIdchanges thenuseGetis called again --useEffectwill fire (becausegetDatahas changed), but it hasn't fired yet, so for nowuseGetreturns 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 5Soon,
useEffectfires, 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.