When fetching data I'm getting: Can't perform a React state update on an unmounted component. The app still works, but react is suggesting I might be causing a memory leak.
This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function."
Why do I keep getting this warning?
I tried researching these solutions:
https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal
https://developer.mozilla.org/en-US/docs/Web/API/AbortController
but this still was giving me the warning.
const ArtistProfile = props => {
const [artistData, setArtistData] = useState(null)
const token = props.spotifyAPI.user_token
const fetchData = () => {
const id = window.location.pathname.split("/").pop()
console.log(id)
props.spotifyAPI.getArtistProfile(id, ["album"], "US", 10)
.then(data => {setArtistData(data)})
}
useEffect(() => {
fetchData()
return () => { props.spotifyAPI.cancelRequest() }
}, [])
return (
<ArtistProfileContainer>
<AlbumContainer>
{artistData ? artistData.artistAlbums.items.map(album => {
return (
<AlbumTag
image={album.images[0].url}
name={album.name}
artists={album.artists}
key={album.id}
/>
)
})
: null}
</AlbumContainer>
</ArtistProfileContainer>
)
}
Edit:
In my api file I added an AbortController() and used a signal so I can cancel a request.
export function spotifyAPI() {
const controller = new AbortController()
const signal = controller.signal
// code ...
this.getArtist = (id) => {
return (
fetch(
`https://api.spotify.com/v1/artists/${id}`, {
headers: {"Authorization": "Bearer " + this.user_token}
}, {signal})
.then(response => {
return checkServerStat(response.status, response.json())
})
)
}
// code ...
// this is my cancel method
this.cancelRequest = () => controller.abort()
}
My spotify.getArtistProfile() looks like this
this.getArtistProfile = (id,includeGroups,market,limit,offset) => {
return Promise.all([
this.getArtist(id),
this.getArtistAlbums(id,includeGroups,market,limit,offset),
this.getArtistTopTracks(id,market)
])
.then(response => {
return ({
artist: response[0],
artistAlbums: response[1],
artistTopTracks: response[2]
})
})
}
but because my signal is used for individual api calls that are resolved in a Promise.all I can't abort() that promise so I will always be setting the state.
For me, clean the state in the unmount of the component helped.
const [state, setState] = useState({});
useEffect(() => {
myFunction();
return () => {
setState({}); // This worked for me
};
}, []);
const myFunction = () => {
setState({
name: 'Jhon',
surname: 'Doe',
})
}
Sharing the AbortController between the fetch() requests is the right approach.
When any of the Promises are aborted, Promise.all() will reject with AbortError:
function Component(props) {
const [fetched, setFetched] = React.useState(false);
React.useEffect(() => {
const ac = new AbortController();
Promise.all([
fetch('http://placekitten.com/1000/1000', {signal: ac.signal}),
fetch('http://placekitten.com/2000/2000', {signal: ac.signal})
]).then(() => setFetched(true))
.catch(ex => console.error(ex));
return () => ac.abort(); // Abort both fetches on unmount
}, []);
return fetched;
}
const main = document.querySelector('main');
ReactDOM.render(React.createElement(Component), main);
setTimeout(() => ReactDOM.unmountComponentAtNode(main), 1); // Unmount after 1ms
<script src="//cdnjs.cloudflare.com/ajax/libs/react/16.8.3/umd/react.development.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.3/umd/react-dom.development.js"></script>
<main></main>
For example, you have some component that does some asynchronous actions, then writes the result to state and displays the state content on a page:
export default function MyComponent() {
const [loading, setLoading] = useState(false);
const [someData, setSomeData] = useState({});
// ...
useEffect( () => {
(async () => {
setLoading(true);
someResponse = await doVeryLongRequest(); // it takes some time
// When request is finished:
setSomeData(someResponse.data); // (1) write data to state
setLoading(false); // (2) write some value to state
})();
}, []);
return (
<div className={loading ? "loading" : ""}>
{someData}
<Link to="SOME_LOCAL_LINK">Go away from here!</Link>
</div>
);
}
Let's say that user clicks some link when doVeryLongRequest() still executes. MyComponent is unmounted but the request is still alive and when it gets a response it tries to set state in lines (1) and (2) and tries to change the appropriate nodes in HTML. We'll get an error from subject.
We can fix it by checking whether compponent is still mounted or not. Let's create a componentMounted ref (line (3) below) and set it true. When component is unmounted we'll set it to false (line (4) below). And let's check the componentMounted variable every time we try to set state (line (5) below).
The code with fixes:
export default function MyComponent() {
const [loading, setLoading] = useState(false);
const [someData, setSomeData] = useState({});
const componentMounted = useRef(true); // (3) component is mounted
// ...
useEffect( () => {
(async () => {
setLoading(true);
someResponse = await doVeryLongRequest(); // it takes some time
// When request is finished:
if (componentMounted.current){ // (5) is component still mounted?
setSomeData(someResponse.data); // (1) write data to state
setLoading(false); // (2) write some value to state
}
return () => { // This code runs when component is unmounted
componentMounted.current = false; // (4) set it to false when we leave the page
}
})();
}, []);
return (
<div className={loading ? "loading" : ""}>
{someData}
<Link to="SOME_LOCAL_LINK">Go away from here!</Link>
</div>
);
}
Why do I keep getting this warning?
The intention of this warning is to help you prevent memory leaks in your application. If the component updates it's state after it has been unmounted from the DOM, this is an indication that there could be a memory leak, but it is an indication with a lot of false positives.
How do I know if I have a memory leak?
You have a memory leak if an object that lives longer than your component holds a reference to it, either directly or indirectly. This usually happens when you subscribe to events or changes of some kind without unsubscribing when your component unmounts from the DOM.
It typically looks like this:
useEffect(() => {
function handleChange() {
setState(store.getState())
}
// "store" lives longer than the component,
// and will hold a reference to the handleChange function.
// Preventing the component to be garbage collected after
// unmount.
store.subscribe(handleChange)
// Uncomment the line below to avoid memory leak in your component
// return () => store.unsubscribe(handleChange)
}, [])
Where store is an object that lives further up the React tree (possibly in a context provider), or in global/module scope. Another example is subscribing to events:
useEffect(() => {
function handleScroll() {
setState(window.scrollY)
}
// document is an object in global scope, and will hold a reference
// to the handleScroll function, preventing garbage collection
document.addEventListener('scroll', handleScroll)
// Uncomment the line below to avoid memory leak in your component
// return () => document.removeEventListener(handleScroll)
}, [])
Another example worth remembering is the web API setInterval, which can also cause memory leak if you forget to call clearInterval when unmounting.
But that is not what I am doing, why should I care about this warning?
React's strategy to warn whenever state updates happen after your component has unmounted creates a lot of false positives. The most common I've seen is by setting state after an asynchronous network request:
async function handleSubmit() {
setPending(true)
await post('/someapi') // component might unmount while we're waiting
setPending(false)
}
You could technically argue that this also is a memory leak, since the component isn't released immediately after it is no longer needed. If your "post" takes a long time to complete, then it will take a long time to for the memory to be released. However, this is not something you should worry about, because it will be garbage collected eventually. In these cases, you could simply ignore the warning.
But it is so annoying to see the warning, how do I remove it?
There are a lot of blogs and answers on stackoverflow suggesting to keep track of the mounted state of your component and wrap your state updates in an if-statement:
let isMountedRef = useRef(false)
useEffect(() => {
isMountedRef.current = true
return () => {
isMountedRef.current = false
}
}, [])
async function handleSubmit() {
setPending(true)
await post('/someapi')
if (!isMountedRef.current) {
setPending(false)
}
}
This is not an recommended approach! Not only does it make the code less readable and adds runtime overhead, but it might also might not work well with future features of React. It also does nothing at all about the "memory leak", the component will still live just as long as without that extra code.
The recommended way to deal with this is to either cancel the asynchronous function (with for instance the AbortController API), or to ignore it.
In fact, React dev team recognises the fact that avoiding false positives is too difficult, and has removed the warning in v18 of React.
You can try this set a state like this and check if your component mounted or not. This way you are sure that if your component is unmounted you are not trying to fetch something.
const [didMount, setDidMount] = useState(false);
useEffect(() => {
setDidMount(true);
return () => setDidMount(false);
}, [])
if(!didMount) {
return null;
}
return (
<ArtistProfileContainer>
<AlbumContainer>
{artistData ? artistData.artistAlbums.items.map(album => {
return (
<AlbumTag
image={album.images[0].url}
name={album.name}
artists={album.artists}
key={album.id}
/>
)
})
: null}
</AlbumContainer>
</ArtistProfileContainer>
)
Hope this will help you.
I had a similar issue with a scroll to top and #CalosVallejo answer solved it :) Thank you so much!!
const ScrollToTop = () => {
const [showScroll, setShowScroll] = useState();
//------------------ solution
useEffect(() => {
checkScrollTop();
return () => {
setShowScroll({}); // This worked for me
};
}, []);
//----------------- solution
const checkScrollTop = () => {
setShowScroll(true);
};
const scrollTop = () => {
window.scrollTo({ top: 0, behavior: "smooth" });
};
window.addEventListener("scroll", checkScrollTop);
return (
<React.Fragment>
<div className="back-to-top">
<h1
className="scrollTop"
onClick={scrollTop}
style={{ display: showScroll }}
>
{" "}
Back to top <span>⟶ </span>
</h1>
</div>
</React.Fragment>
);
};
I have getting same warning, This solution Worked for me ->
useEffect(() => {
const unsubscribe = fetchData(); //subscribe
return unsubscribe; //unsubscribe
}, []);
if you have more then one fetch function then
const getData = () => {
fetch1();
fetch2();
fetch3();
}
useEffect(() => {
const unsubscribe = getData(); //subscribe
return unsubscribe; //unsubscribe
}, []);
This error occurs when u perform state update on current component after navigating to other component:
for example
axios
.post(API.BASE_URI + API.LOGIN, { email: username, password: password })
.then((res) => {
if (res.status === 200) {
dispatch(login(res.data.data)); // line#5 logging user in
setSigningIn(false); // line#6 updating some state
} else {
setSigningIn(false);
ToastAndroid.show(
"Email or Password is not correct!",
ToastAndroid.LONG
);
}
})
In above case on line#5 I'm dispatching login action which in return navigates user to the dashboard and hence login screen now gets unmounted.
Now when React Native reaches as line#6 and see there is state being updated, it yells out loud that how do I do this, the login component is there no more.
Solution:
axios
.post(API.BASE_URI + API.LOGIN, { email: username, password: password })
.then((res) => {
if (res.status === 200) {
setSigningIn(false); // line#6 updating some state -- moved this line up
dispatch(login(res.data.data)); // line#5 logging user in
} else {
setSigningIn(false);
ToastAndroid.show(
"Email or Password is not correct!",
ToastAndroid.LONG
);
}
})
Just move react state update above, move line 6 up the line 5.
Now state is being updated before navigating the user away. WIN WIN
there are many answers but I thought I could demonstrate more simply how the abort works (at least how it fixed the issue for me):
useEffect(() => {
// get abortion variables
let abortController = new AbortController();
let aborted = abortController.signal.aborted; // true || false
async function fetchResults() {
let response = await fetch(`[WEBSITE LINK]`);
let data = await response.json();
aborted = abortController.signal.aborted; // before 'if' statement check again if aborted
if (aborted === false) {
// All your 'set states' inside this kind of 'if' statement
setState(data);
}
}
fetchResults();
return () => {
abortController.abort();
};
}, [])
Other Methods:
https://medium.com/wesionary-team/how-to-fix-memory-leak-issue-in-react-js-using-hook-a5ecbf9becf8
If the user navigates away, or something else causes the component to get destroyed before the async call comes back and tries to setState on it, it will cause the error. It's generally harmless if it is, indeed, a late-finish async call. There's a couple of ways to silence the error.
If you're implementing a hook like useAsync you can declare your useStates with let instead of const, and, in the destructor returned by useEffect, set the setState function(s) to a no-op function.
export function useAsync<T, F extends IUseAsyncGettor<T>>(gettor: F, ...rest: Parameters<F>): IUseAsync<T> {
let [parameters, setParameters] = useState(rest);
if (parameters !== rest && parameters.some((_, i) => parameters[i] !== rest[i]))
setParameters(rest);
const refresh: () => void = useCallback(() => {
const promise: Promise<T | void> = gettor
.apply(null, parameters)
.then(value => setTuple([value, { isLoading: false, promise, refresh, error: undefined }]))
.catch(error => setTuple([undefined, { isLoading: false, promise, refresh, error }]));
setTuple([undefined, { isLoading: true, promise, refresh, error: undefined }]);
return promise;
}, [gettor, parameters]);
useEffect(() => {
refresh();
// and for when async finishes after user navs away //////////
return () => { setTuple = setParameters = (() => undefined) }
}, [refresh]);
let [tuple, setTuple] = useState<IUseAsync<T>>([undefined, { isLoading: true, refresh, promise: Promise.resolve() }]);
return tuple;
}
That won't work well in a component, though. There, you can wrap useState in a function which tracks mounted/unmounted, and wraps the returned setState function with the if-check.
export const MyComponent = () => {
const [numPendingPromises, setNumPendingPromises] = useUnlessUnmounted(useState(0));
// ..etc.
// imported from elsewhere ////
export function useUnlessUnmounted<T>(useStateTuple: [val: T, setVal: Dispatch<SetStateAction<T>>]): [T, Dispatch<SetStateAction<T>>] {
const [val, setVal] = useStateTuple;
const [isMounted, setIsMounted] = useState(true);
useEffect(() => () => setIsMounted(false), []);
return [val, newVal => (isMounted ? setVal(newVal) : () => void 0)];
}
You could then create a useStateAsync hook to streamline a bit.
export function useStateAsync<T>(initialState: T | (() => T)): [T, Dispatch<SetStateAction<T>>] {
return useUnlessUnmounted(useState(initialState));
}
Try to add the dependencies in useEffect:
useEffect(() => {
fetchData()
return () => { props.spotifyAPI.cancelRequest() }
}, [fetchData, props.spotifyAPI])
Usually this problem occurs when you showing the component conditionally, for example:
showModal && <Modal onClose={toggleModal}/>
You can try to do some little tricks in the Modal onClose function, like
setTimeout(onClose, 0)
This works for me :')
const [state, setState] = useState({});
useEffect( async ()=>{
let data= await props.data; // data from API too
setState(users);
},[props.data]);
I had this problem in React Native iOS and fixed it by moving my setState call into a catch. See below:
Bad code (caused the error):
const signupHandler = async (email, password) => {
setLoading(true)
try {
const token = await createUser(email, password)
authContext.authenticate(token)
} catch (error) {
Alert.alert('Error', 'Could not create user.')
}
setLoading(false) // this line was OUTSIDE the catch call and triggered an error!
}
Good code (no error):
const signupHandler = async (email, password) => {
setLoading(true)
try {
const token = await createUser(email, password)
authContext.authenticate(token)
} catch (error) {
Alert.alert('Error', 'Could not create user.')
setLoading(false) // moving this line INTO the catch call resolved the error!
}
}
Similar problem with my app, I use a useEffect to fetch some data, and then update a state with that:
useEffect(() => {
const fetchUser = async() => {
const {
data: {
queryUser
},
} = await authFetch.get(`/auth/getUser?userId=${createdBy}`);
setBlogUser(queryUser);
};
fetchUser();
return () => {
setBlogUser(null);
};
}, [_id]);
This improves upon Carlos Vallejo's answer.
useEffect(() => {
let abortController = new AbortController();
// your async action is here
return () => {
abortController.abort();
}
}, []);
in the above code, I've used AbortController to unsubscribe the effect. When the a sync action is completed, then I abort the controller and unsubscribe the effect.
it work for me ....
The easy way
let fetchingFunction= async()=>{
// fetching
}
React.useEffect(() => {
fetchingFunction();
return () => {
fetchingFunction= null
}
}, [])
options={{
filterType: "checkbox"
,
textLabels: {
body: {
noMatch: isLoading ?
:
'Sorry, there is no matching data to display',
},
},
}}
In my custom hook, I am getting all the images in firestore collection. That was working fine UNTIL I have tried to get also metadata's of images. When I added the getMetaData function, I can't set the data to state even the datas seem fine in an array.
See the code please.
import { useState, useEffect } from "react";
import { firebaseFireStore } from "../firebase/config";
import { storage } from "../firebase/config";
import { ref, getMetadata } from "firebase/storage";
const useFirestore = (collection) => {
const [docs, setDocs] = useState([]);
useEffect(() => {
const unsub = firebaseFireStore
.collection(collection)
.onSnapshot((snap) => {
let documents = [];
snap.forEach((doc) => {
const forestRef = ref(storage, doc.data().url);
getMetadata(forestRef)
.then((metadata) => {
documents.push({ ...doc.data(), metadata, id: doc.id });
return documents;
})
.catch((error) => {
console.log(error);
});
});
setDocs(documents);
console.log(docs); // [] EMPTY ARRAY, PROBLEM IS HERE.
console.log(documents); // [] length:13 data is complete inside.
});
return () => unsub();
// this is a cleanup function that react will run when
// a component using the hook unmounts
}, []);
return { docs };
};
export default useFirestore;
So, even if documents array has the data with metadata in it, it can't be set to state of docs.
Any help appreciated.
You can't log the docs state right after enqueueing a state update since React asynchronously processes the state updates. But this isn't exactly your issue or what you are actually trying to solve. The code is mapping over the snapshot and enqueueing an asynchronous request to retrieve metadata and asynchronously mutating the documents array that was updated in React a long* time ago. The console.log(documents) is just exposing this mutation later when the browser is processing the log buffer.
To resolve I suggest declaring the snapshot.forEach callback async so it can wait for the getMetadata call to resolve. Use a functional state update to shallow merge each document as it resolves with metadata.
Example:
useEffect(() => {
const unsubscribe = firebaseFireStore
.collection(collection)
.onSnapshot((snap) => {
snap.forEach(async (doc) => {
try {
const forestRef = ref(storage, doc.data().url);
const metadata = await getMetadata(forestRef);
setDocs(documents => [
...documents,
{ ...doc.data(), metadata, id: doc.id }
]);
} catch(error) {
console.log(error);
};
});
});
return unsubscribe;
}, []);
* "Long time ago" -> some previous render cycle.
Within my React component, I have an async request which dispatches an action to my Redux store which is called within the useEffect hook:
const loadFields = async () => {
setIsLoading(true);
try {
await dispatch(fieldsActions.fetchFields(user.client.id));
} catch (error) {
setHasError(true);
}
setIsLoading(false);
}
useEffect(() => { if(isOnline) { loadFields() } }, [dispatch, isOnline]);
The action requests data via a fetch request:
export const fetchFields = clientId => {
return async dispatch => {
try {
const response = await fetch(
Api.baseUrl + clientId + '/fields',
{ headers: { 'Apiauthorization': Api.token } }
);
if (!response.ok) {
throw new Error('Something went wrong!');
}
const resData = await response.json();
dispatch({ type: SET_FIELDS, payload: resData.data });
} catch (error) {
throw error;
}
}
};
export const setFields = fields => ({
type : SET_FIELDS,
payload : fields
});
When this is rendered within the React app it results in the following warning:
Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in %s.%s, a useEffect cleanup function
I believe this occurs because the promise doesn't have a "clean-up" function. But I am unsure where to place this? Should I have some logic within LoadFields()? Or must this be done within the useEffect hook?
This tutorial which will help you to resolve your issue.
Quick example: with Promises
function BananaComponent() {
const [bananas, setBananas] = React.useState([])
React.useEffect(() => {
let isSubscribed = true
fetchBananas().then( bananas => {
if (isSubscribed) {
setBananas(bananas)
}
})
return () => isSubscribed = false
}, []);
return (
<ul>
{bananas.map(banana => <li>{banana}</li>)}
</ul>
)
}
Quick example: with async/await (Not the best one but that should work with an anonymous function)
function BananaComponent() {
const [bananas, setBananas] = React.useState([])
React.useEffect(() => {
let isSubscribed = true
async () => {
const bananas = await fetchBananas();
if (isSubscribed) {
setBananas(bananas)
}
})();
return () => isSubscribed = false
}, []);
return (
<ul>
{bananas.map(banana => <li>{banana}</li>)}
</ul>
)
}
First issue
If your useEffect() fetches data acynchronously then it would be a very good idea to have a cleanup function to cancel the non-completed fetch. Otherwise what could happen is like that: fetch takes longer than expected, meantime the component is re-rendered for whatever reason. Maybe because its parent is re-rendered. The cleanup of useEffect runs before re-render and the useEffect itself runs after re-render. To avoid having another fetch inflight it's better to cancel the previous one. Sample code:
const [data, setData] = useState();
useEffect(() => {
const controller = new AbortController();
const fetchData = async () => {
try {
const apiData = await fetch("https://<yourdomain>/<api-path>",
{ signal: controller.signal });
setData(apiData);
} catch (err) {
if (err.name === 'AbortError') {
console.log("Request aborted");
return;
}
}
};
fetchData();
return () => {
controller.abort();
}
});
Second issue
This code
return async dispatch => {
will not work because neither dispatch nor Redux store support async actions. The most flexible and powerful way to handle this issue is to use middleware like redux-saga. The middleware lets you:
dispatch 'usual' sync actions to Redux store.
intercept those sync actions and in response make one or several async calls doing whatever you want.
wait until async call(s) finish and in response dispatch one or several sync actions to Redux store, either the original ones which you intercepted or different ones.
I want to start adding loading screens to my components when I'm fetching data from my API. I've made some research and tried my best, but my loading screen never disappears and I don't know why. I've tried to solve this for a while now hehe.
Action:
const setDoors = data => {
return {
type: 'SET_DOORS',
payload: {
setDoors: data
}
}
}
...
export const fetchDoors = () => async (dispatch, getState) => {
try {
dispatch({ type: 'FETCH_DOORS' })
const doors = await axios.get(`${settings.hostname}/locks`).data
dispatch(setDoors(doors))
Reducer:
const initialState = {
isLoading: false
}
export const fetchDoors = (state = initialState, action) => {
switch (action.type) {
case 'FETCH_DOORS':
return { ...state, isLoading: true }
case 'FETCH_DOORS_SUCCESS':
return { ...state, doors: action.payload.setDoors, isLoading: false }
In my component I only have this:
if (this.props.isLoading) return <div>laddar</div>
And when I log it I'm always getting true:
const mapStateToProps = state => {
console.log(state.fetchDoors.isLoading) // Always true
return {
doors: state.fetchDoors.doors,
isLoading: state.fetchDoors.isLoading,
controllers: state.fetchDoors.controllers
}
}
Console
You have a tricky small error in that you are not awaiting what you think you are awaiting.
Compare your
const doors = await axios.get(`${settings.hostname}/locks`).data
to
const doors = (await axios.get(`${settings.hostname}/locks`)).data
In the first case you are actually awaiting some undefined property .data on the promise (not the awaited result) that gets returned from the axios call.
In the second case, which should work, you're awaiting the promise, and then you get the .data property of the awaited promise.
I reproduced the issue in the small snippet below. Notice how fast the first result pops up, even though the promise is supposed to resolve after 4 seconds.
const getDataPromise = () => new Promise(resolve => setTimeout(() => resolve({data: 42}), 4000));
(async function e() {
const data = await getDataPromise().data;
console.log("wrong: await getDataPromise().data = ", data)
})();
(async function e() {
const data = (await getDataPromise()).data;
console.log("right: (await getDataPromise()).data = ", data)
})();
dispatch(setDoors(doors)) should be dispatch(fetchDoorsSucess(doors)) and should only be called upon a sucessfull axios call.
const fetchDoorsSuccess = data => {
return {
type: 'FETCH_DOORS_SUCCESS',
payload: {
setDoors: data
}
}
}
In general, I have three standard actions for each ajax call, a REQUEST, REQUEST_SUCCESS, and REQUEST_ERROR. In the reducer for REQUEST, you should set isLoading:true, for the other two, you should set isLoading: false along with reducing any data you got back from the server.
So far I like Redux better than other Flux implementations, and I'm using it to re-write our front end application.
The main struggling points that I'm facing:
Maintaining the status of API calls to avoid sending duplicate requests.
Maintaining relationships between records.
The first issue could be solved by keeping a status field in the sub-state of each type of data. E.g.:
function postsReducer(state, action) {
switch(action.type) {
case "FETCH_POSTS":
return {
...state,
status: "loading",
};
case "LOADED_POSTS":
return {
status: "complete",
posts: action.posts,
};
}
}
function commentsReducer(state, action) {
const { type, postId } = action;
switch(type) {
case "FETCH_COMMENTS_OF_POST":
return {
...state,
status: { ...state.status, [postId]: "loading" },
};
case "LOADED_COMMENTS_OF_POST":
return {
status: { ...state.status, [postId]: "complete" },
posts: { ...state.posts, [postId]: action.posts },
};
}
}
Now I can make a Saga for Posts and another one for Comments. Each of the Sagas knows how to get the status of requests. But that would lead to a lot of duplicate code soon (e.g. Posts, Comments, Likes, Reactions, Authors, etc).
I'm wondering if there is a good way to avoid all that duplicate code.
The 2nd issue comes to existence when I need to get a comment by ID from the redux store. Are there best practices for handling relationships between data?
Thanks!
redux-saga now has takeLeading(pattern, saga, ...args)
Version 1.0+ of redux-saga has takeLeading that spawns a saga on each action dispatched to the Store that matches pattern. After spawning a task once, it blocks until the spawned saga completes and then starts to listen for a pattern again.
Previously I implemented this solution from the owner of Redux Saga and it worked really well - I was getting errors from API calls sometimes being fired twice:
You could create a higher order saga for this, which would look something like this:
function* takeOneAndBlock(pattern, worker, ...args) {
const task = yield fork(function* () {
while (true) {
const action = yield take(pattern)
yield call(worker, ...args, action)
}
})
return task
}
and use it like this:
function* fetchRequest() {
try {
yield put({type: 'FETCH_START'});
const res = yield call(api.fetch);
yield put({type: 'FETCH_SUCCESS'});
} catch (err) {
yield put({type: 'FETCH_FAILURE'});
}
}
yield takeOneAndBlock('FETCH_REQUEST', fetchRequest)
In my opinion this way is far way more elegant and also its behaviour can be easily customized depending on your needs.
I had the exact same issue in my project.
I have tried redux-saga, it seems that it's really a sensible tool to control the data flow with redux on side effects. However, it's a little complex to deal with the real world problem such as duplicate requests and handling relationships between data.
So I created a small library 'redux-dataloader' to solve this problem.
Action Creators
import { load } from 'redux-dataloader'
function fetchPostsRequest() {
// Wrap the original action with load(), it returns a Promise of this action.
return load({
type: 'FETCH_POSTS'
});
}
function fetchPostsSuccess(posts) {
return {
type: 'LOADED_POSTS',
posts: posts
};
}
function fetchCommentsRequest(postId) {
return load({
type: 'FETCH_COMMENTS',
postId: postId
});
}
function fetchCommentsSuccess(postId, comments) {
return {
type: 'LOADED_COMMENTS_OF_POST',
postId: postId,
comments: comments
}
}
Create side loaders for request actions
Then create data loaders for 'FETCH_POSTS' and 'FETCH_COMMENTS':
import { createLoader, fixedWait } from 'redux-dataloader';
const postsLoader = createLoader('FETCH_POSTS', {
success: (ctx, data) => {
// You can get dispatch(), getState() and request action from ctx basically.
const { postId } = ctx.action;
return fetchPostsSuccess(data);
},
error: (ctx, errData) => {
// return an error action
},
shouldFetch: (ctx) => {
// (optional) this method prevent fetch()
},
fetch: async (ctx) => {
// Start fetching posts, use async/await or return a Promise
// ...
}
});
const commentsLoader = createLoader('FETCH_COMMENTS', {
success: (ctx, data) => {
const { postId } = ctx.action;
return fetchCommentsSuccess(postId, data);
},
error: (ctx, errData) => {
// return an error action
},
shouldFetch: (ctx) => {
const { postId } = ctx.action;
return !!ctx.getState().comments.comments[postId];
},
fetch: async (ctx) => {
const { postId } = ctx.action;
// Start fetching comments by postId, use async/await or return a Promise
// ...
},
}, {
// You can also customize ttl, and retry strategies
ttl: 10000, // Don't fetch data with same request action within 10s
retryTimes: 3, // Try 3 times in total when error occurs
retryWait: fixedWait(1000), // sleeps 1s before retrying
});
export default [
postsLoader,
commentsLoader
];
Apply redux-dataloader to redux store
import { createDataLoaderMiddleware } from 'redux-dataloader';
import loaders from './dataloaders';
import rootReducer from './reducers/index';
import { createStore, applyMiddleware } from 'redux';
function configureStore() {
const dataLoaderMiddleware = createDataLoaderMiddleware(loaders, {
// (optional) add some helpers to ctx that can be used in loader
});
return createStore(
rootReducer,
applyMiddleware(dataLoaderMiddleware)
);
}
Handle data chain
OK, then just use dispatch(requestAction) to handle relationships between data.
class PostContainer extends React.Component {
componentDidMount() {
const dispatch = this.props.dispatch;
const getState = this.props.getState;
dispatch(fetchPostsRequest()).then(() => {
// Always get data from store!
const postPromises = getState().posts.posts.map(post => {
return dispatch(fetchCommentsRequest(post.id));
});
return Promise.all(postPromises);
}).then() => {
// ...
});
}
render() {
// ...
}
}
export default connect(
state => ()
)(PostContainer);
NOTICE The promised of request action with be cached within ttl, and prevent duplicated requests.
BTW, if you are using async/await, you can handle data fetching with redux-dataloader like this:
async function fetchData(props, store) {
try {
const { dispatch, getState } = store;
await dispatch(fetchUserRequest(props.userId));
const userId = getState().users.user.id;
await dispatch(fetchPostsRequest(userId));
const posts = getState().posts.userPosts[userId];
const commentRequests = posts.map(post => fetchCommentsRequest(post.id))
await Promise.all(commentRequests);
} catch (err) {
// error handler
}
}
First, you can create a generic action creator for fetching post.
function fetchPost(id) {
return {
type: 'FETCH_POST_REQUEST',
payload: id,
};
}
function fetchPostSuccess(post, likes, comments) {
return {
type: 'FETCH_POST_SUCCESS',
payload: {
post,
likes,
comments,
},
};
}
When you call this fetch post action, it'll trigger onFetchPost saga.
function* watchFetchPost() {
yield* takeLatest('FETCH_POST_REQUEST', onFetchPost);
}
function* onFetchPost(action) {
const id = action.payload;
try {
// This will do the trick for you.
const [ post, likes, comments ] = yield [
call(Api.getPost, id),
call(Api.getLikesOfPost, id),
call(Api.getCommentsOfPost, id),
];
// Instead of dispatching three different actions, heres just one!
yield put(fetchPostSuccess(post, likes, comments));
} catch(error) {
yield put(fetchPostFailure(error))
}
}