useLazyQuery causing too many re-renders [Apollo/ apollo/react hooks] - reactjs

I'm building a discord/slack clone. I have Channels, Messages and users.
As soon as my Chat component loads, channels get fetched with useQuery hook from Apollo.
By default when a users comes at the Chat component, he needs to click on a specific channels to see the info about the channel and also the messages.
In the smaller Channel.js component I write the channelid of the clicked Channel to the apollo-cache. This works perfect, I use the useQuery hooks #client in the Messages.js component to fetch the channelid from the cache and it's working perfect.
The problem shows up when I use the useLazyQuery hook for fetching the messages for a specific channel (the channel the user clicks on).
It causes a infinite re-render loop in React causing the app to crash.
I've tried working with the normal useQuery hook with the skip option. I then call the refetch() function when I need it. This 'works' in the sense of it not giving me infinite loop.
But then the console.log() give me this error: [GraphQL error]: Message: Variable "$channelid" of required type "String!" was not provided. Path: undefined. This is very weird because my schema and variables are correct ??
The useLazyQuery does give me infinite loop as said before.
I'm really struggling with the conditionality of apollo/react hooks...
/// Channel.js component ///
const Channel = ({ id, channelName, channelDescription, authorName }) => {
const chatContext = useContext(ChatContext);
const client = useApolloClient();
const { fetchChannelInfo, setCurrentChannel } = chatContext;
const selectChannel = (e, id) => {
fetchChannelInfo(true);
const currentChannel = {
channelid: id,
channelName,
channelDescription,
authorName
};
setCurrentChannel(currentChannel);
client.writeData({
data: {
channelid: id
}
});
// console.log(currentChannel);
};
return (
<ChannelNameAndLogo onClick={e => selectChannel(e, id)}>
<ChannelLogo className='fab fa-slack-hash' />
<ChannelName>{channelName}</ChannelName>
</ChannelNameAndLogo>
);
};
export default Channel;
/// Messages.js component ///
const FETCH_CHANNELID = gql`
{
channelid #client
}
`;
const Messages = () => {
const [messageContent, setMessageContent] = useState('');
const chatContext = useContext(ChatContext);
const { currentChannel } = chatContext;
// const { data, loading, refetch } = useQuery(FETCH_MESSAGES, {
// skip: true
// });
const { data: channelidData, loading: channelidLoading } = useQuery(
FETCH_CHANNELID
);
const [fetchMessages, { data, called, loading, error }] = useLazyQuery(
FETCH_MESSAGES
);
//// useMutation is working
const [
createMessage,
{ data: MessageData, loading: MessageLoading }
] = useMutation(CREATE_MESSAGE);
if (channelidLoading && !channelidData) {
console.log('loading');
setInterval(() => {
console.log('loading ...');
}, 1000);
} else if (!channelidLoading && channelidData) {
console.log('not loading anymore...');
console.log(channelidData.channelid);
fetchMessages({ variables: { channelid: channelidData.channelid } });
console.log(data);
}
I expect to have messages in data from the useLazyQuery ...But instead get this in the console.log():
react-dom.development.js:16408 Uncaught Invariant Violation: Too many re-renders. React limits the number of renders to prevent an infinite loop.

You could use the called variable return by useLazyQuery.
!called && fetchMessages({ variables: { channelid: channelidData.channelid } });

You call fetchMessages on every render.
Try to put fetchMessages in a useEffect :
useEffect(() => {
if (!channelidLoading && channelidData) {
fetchMessages();
}
}, [channelidLoading, channelidData]);
Like that the fetchMessages function only calls when
channelidLoading or channelidData is changing.

You could also look at doing the following:
import debounce from 'lodash.debounce';
...
const [fetchMessages, { data, called, loading, error }] = useLazyQuery(
FETCH_MESSAGES
);
const findMessageButChill = debounce(fetchMessages, 350);
...
} else if (!channelidLoading && channelidData) {
findMessageButChill({
variables: { channelid: channelidData.channelid },
});
}

Related

Apollo Client Canceling Requests when more than one hook is used

I have a hook (useDashboardData) that calls another hook (useItems) that's just a wrapper for two Apollo client queries.
inside the first hook useDashboardData, i'm also calling another hook useOtherItems that also calls another Apollo client query.
export const useDashboardData = () => {
const { item, isItemsLoading } = useItems();
const { list, isOtherItemsLoading } = useOtherItems();
const dashboardData = {
items: {
itemsLoading: isItemsLoading,
itemsData: item,
},
otherItems: {
otherItemsLoading: isOtherItemsLoading,
otherItemsData: list,
},
};
return {
dashboardProps: {
dashboardData: dashboardData,
},
};
};
useItems.tsx
export const useItems = () => {
const { user } = useAuthorization();
const {
data: itemData,
loading: items Loading,
} = useCustomApolloGetItemsQuery({
skip: !user.id,
variables: { user.id },
});
const {
data: moreItemData,
loading: moreItemsLoading,
} = useAnotherApolloGetItemsQuery({
skip: !user.id,
variables: { user.id },
});
const combinedItems = combineItemData(itemData, moreItemData);
return { combinedItems, ItemsLoading };
useOtherItems.tsx
export const useOtherItems = () => {
const { user } = useAuthorization();
const { data: list, loading: isOtherItemsLoading } = useGetInvoiceListQuery({
skip: !user.id,
variables: {
userId: user.id,
},
});
return { list, isOtherItemsLoading };
For some reason, anytime I introduce the second hook, the previous requests get canceled. which one is arbitrary but it's consistently canceled.
I'm pretty sure it's due to the first hook request resolving earlier and causing a re-render before the request in the second hook is resolved.
I need to figure out the right pattern to deal with this.
**note I have made sure the Apollo Client is only instantiated once so it's not that.

Axios throwing CanceledError with Abort controller in react

I have built an axios private instance with interceptors to manage auth request.
The system has a custom axios instance:
const BASE_URL = 'http://localhost:8000';
export const axiosPrivate = axios.create({
baseURL: BASE_URL,
headers: {
'Content-Type': 'application/json',
},
withCredentials: true,
});
A custom useRefreshToken hook returns accessToken using the refresh token:
const useRefreshToken = () => {
const { setAuth } = useAuth();
const refresh = async () => {
const response = await refreshTokens();
// console.log('response', response);
const { user, roles, accessToken } = response.data;
setAuth({ user, roles, accessToken });
// return accessToken for use in axiosClient
return accessToken;
};
return refresh;
};
export default useRefreshToken;
Axios interceptors are attached to this axios instance in useAxiosPrivate.js file to attached accessToken to request and refresh the accessToken using a refresh token if expired.
const useAxiosPrivate = () => {
const { auth } = useAuth();
const refresh = useRefreshToken();
useEffect(() => {
const requestIntercept = axiosPrivate.interceptors.request.use(
(config) => {
// attach the access token to the request if missing
if (!config.headers['Authorization']) {
config.headers['Authorization'] = `Bearer ${auth?.accessToken}`;
}
return config;
},
(error) => Promise.reject(error)
);
const responseIntercept = axiosPrivate.interceptors.response.use(
(response) => response,
async (error) => {
const prevRequest = error?.config;
// sent = custom property, after 1st request - sent = true, so no looping requests
if (error?.response?.status === 403 && !prevRequest?.sent) {
prevRequest.sent = true;
const newAccessToken = await refresh();
prevRequest.headers['Authorization'] = `Bearer ${newAccessToken}`;
return axiosPrivate(prevRequest);
}
return Promise.reject(error);
}
);
// remove the interceptor when the component unmounts
return () => {
axiosPrivate.interceptors.response.eject(responseIntercept);
axiosPrivate.interceptors.request.eject(requestIntercept);
};
}, [auth, refresh]);
return axiosPrivate;
};
export default useAxiosPrivate;
Now, this private axios instance is called in functional component - PanelLayout which is used to wrap around the pages and provide layout.
Here, I've tried to use AbortControllers in axios to terminate the request after the component is mounted.
function PanelLayout({ children, title }) {
const [user, setUser] = useState(null);
const axiosPrivate = useAxiosPrivate();
const router = useRouter();
useEffect(() => {
let isMounted = true;
const controller = new AbortController();
const signal = controller.signal;
const getUserProfile = async () => {
try {
const response = await axiosPrivate.get('/api/identity/profile', {
signal,
});
console.log(response.data);
isMounted && setUser(response.data.user);
} catch (error) {
console.log(error);
router.push({
pathname: '/seller/auth/login',
query: { from: router.pathname },
});
}
};
getUserProfile();
return () => {
isMounted = false;
controller.abort();
};
}, []);
console.log('page rendered');
return (
<div className='flex items-start'>
<Sidebar className='h-screen w-[10rem]' />
<section className='min-h-screen flex flex-col'>
<PanelHeader title={title} classname='left-[10rem] h-[3.5rem]' />
<main className='mt-[3.5rem] flex-1'>{children}</main>
</section>
</div>
);
}
export default PanelLayout;
However, the above code is throwing the following error:
CanceledError {message: 'canceled', name: 'CanceledError', code: 'ERR_CANCELED'}
code: "ERR_CANCELED"
message: "canceled"
name: "CanceledError"
[[Prototype]]: AxiosError
constructor: ƒ CanceledError(message)
__CANCEL__: true
[[Prototype]]: Error
Please suggest how to avoid the above error and get axios to work properly.
I also encountered the same issue and I thought that there was some flaw in my logic which caused the component to be mounted twice. After doing some digging I found that react apparently added this feature with with the new version 18 in StrictMode where useEffect was being run twice. Here's a link to the article clearly explaining this new behaviour.
One way you could solve this problem is by removing StrictMode from your application (Temporary Solution)
Another way is by using useRef hook to store some piece of state which is updated when your application is mounted the second time.
// CODE BEFORE USE EFFECT
const effectRun = useRef(false);
useEffect(() => {
let isMounted = true;
const controller = new AbortController();
const signal = controller.signal;
const getUserProfile = async () => {
try {
const response = await axiosPrivate.get('/api/identity/profile', {
signal,
});
console.log(response.data);
isMounted && setUser(response.data.user);
} catch (error) {
console.log(error);
router.push({
pathname: '/seller/auth/login',
query: { from: router.pathname },
});
}
};
// Check if useEffect has run the first time
if (effectRun.current) {
getUserProfile();
}
return () => {
isMounted = false;
controller.abort();
effectRun.current = true; // update the value of effectRun to true
};
}, []);
// CODE AFTER USE EFFECT
Found the solution from this YouTube video.
I, too, encountered this issue. What made it worse is that axios doesn't provide an HTTP status code when the request has been canceled, although you do get error.code === "ERR_CANCELED". I solved it by handling the abort within the axios interceptor:
axiosInstance.interceptors.response.use(
(response) => response,
(error) => {
if (error.code === "ERR_CANCELED") {
// aborted in useEffect cleanup
return Promise.resolve({status: 499})
}
return Promise.reject((error.response && error.response.data) || 'Error')
}
);
As you can see, I ensure that the error response in the case of an abort supplies a status code of 499.
I faced the same problem in similar project, lets start by understanding first the root cause of that problem.
in react 18 the try to make us convenient to the idea of mounting and unmounting components twice for future features that the are preparing, the the useEffect hook now is mounted first time then unmounted the mounted finally.
so they need from us adapt our projects to the idea of mount and unmount of components twice
so you have two ways, adapting these changes and try to adapt your code to accept mounting twice, or making some turn around code to overcome mounting twice, and I would prefer the first one.
here in your code after first mount you aborted your API request in clean up function, so when the component dismount and remount again it face an error when try to run previously aborted request, so it throw exception, that's what happens
1st solution (adapting to react changing):
return () => {
isMounted = false
isMounted && controller.abort()
}
so in above code we will abort controller once only when isMounted is true, and thats will solve your problem
2nd solution (turn around to react changing):
by using useRef hook and asign it to a variable and update its boolean value after excuting the whole code only one time.
const runOnce = useRef(true)
useEffect(()=>{
if(runOnce.current){
//requesting from API
return()=>{
runOnce.current = false
}
}
},[])
3rd solution (turn around to react changing):
remove React.StrictMode from index.js file

How to show an error to the user using react and zustand stores

I have the following problem. How should I show an error to the user using a zustand for storing my data? I have a function showError that I am using through my react application to show an error. The idea is I pass an error message and a toast is shown to the user.
ItemsStore.ts
try {
const currentItem = await getItem(itemId);
set(state => {
state.items = [...state.items, currentItem]
});
} catch (error){
// Example error: Item doesn't exist.
// How to show my error to the user
// I can't use my showError function here,
// it should be inside a component to not
// break the rules of hooks
}
const MyComponent = () => {
const items = useItemStore(state=>state.items);
// I don't have an access what happens intern in my store
// If error occurs the items are an empty array.
}
In my Zustand project, I created a global error store.
const setError = (set) => ({scope, errorMsg}) => {
return set(state => ({
...state,
error: {
...state.error,
[`${scope}Error`]: errorMsg,
}
})}
You can call setError in your catch block:
try {
const currentItem = await getItem(itemId);
set(state => {
state.items = [...state.items, currentItem]
});
} catch (error){
const { setError } = get().error
// I often use function names as scopes
setError("fnName", error.message)
}
Then you can access the error msg in your component
const MyComponent = () => {
const items = useItemStore(state=>state.items);
const fnNameError = useItemStore(state=>state.error.fnNameError);
}

Why does Apollo useQuery hook cause an infinite re-render on error?

I'm using Apollo's useQuery hook along with the graphql.macro library to load queries from .graphql files. This is in a create-react-app project, so no custom webpack configuration.
The component always starts out in the loading state and works if the network request is successful. However, if the network request is unsuccessful, the component re-renders and retries the request infinitely. The solution is to move the call to loader outside of the useData custom hook, but why is that? Is the useQuery hook internally memoizing requests based on the DocumentNode? If so, why does this only occur in the error state and not when the request succeeds?
useData.ts
export const useData = (dataID: string = '') => {
const dataQuery = loader('../graphql/queryData.graphql');
const { data, error, loading } = useQuery<IData>(dataQuery, {
variables: { id: dataID },
});
return { data, error, loading };
};
component.tsx
export const ViewData: React.FunctionComponent<Props> = ({ dataID }) => {
const { data, error, loading } = useData(dataID);
if (loading) {
return <div>Loading Data...</div>;
}
if (error) {
return (
<div>
{`Failed to find data ${dataID}, please check the ID and try again.`}
<div/>
);
}
const { name, type } = data;
return (
<div>
{ `Displaying ${name} of ${type}` }
</div>
);
};
Thanks for the help!

useMutation onCompleted function is not invoked in react unit testing

This is how my component look likes.
Inside the component I am making a mutation and the handleOnComplete function is invoked once the mutation query is completed.
This code is working fine.
function BrandingSearch() {
const [searchList, setSearchList] = useState([]);
const [searchQuery, { loading, error }] = useMutation(SEARCH_NO_INDEX, {
onCompleted: handleOnComplete,
variables: {
rootType: 'branding',
input: {}
}
});
useEffect(() => {
searchQuery();
}, [])
function handleOnComplete(res) {
if (res && res.searchNoIndex && res.searchNoIndex.payload) {
const payload = res.searchNoIndex.payload;
const dataList = payload.map((item) => ({ ...item, id: item.name })); //Get requests require name instead of id
setSearchList(dataList)
}
}
const component = (
<CardSearch
searchList={searchList}
/>
)
return component;
}
export default BrandingSearch;
Below is my testcase, useEffect is being invoked in the testcase, but handleOnComplete is not being invoked.
How can I fix this.
Testcase:
describe('Test BrandingSearch', () => {
it('', async () => {
let deleteMutationCalled = false;
const mocks = [
{
request: {
query: SEARCH_NO_INDEX,
variables: {
rootType: 'branding',
input: {}
}
},
result: () => {
deleteMutationCalled = true;
return { data:{} };
}
}
];
let wrapper;
act(() => {
wrapper = create(
<MockedProvider mocks={mocks} addTypename={false}>
<BrandingSearch />
</MockedProvider>
)
})
let component = wrapper.root;
expect(deleteMutationCalled).toBe(true);
//Expecting this to be set true, once the mutation is fired.
})
});
Any help is appreciated.
After hours of reading through articles and threads, I finally figured the issue out with some hit & trial.
Cause: As we know, the useQuery, useLazyQuery and useMutation are async calls. So when these are called, they make the API call, wait for the response, and then process the onCompleted or onError callbacks.
Let's consider a useQuery hook. When we call render() in our test cases, the hook is called upon mounting but the test terminates way before the async call gets finished and the onCompleted doesn't even get the chance to execute. The test hence uses the initially added states and doesn't update based on the data we provide in the mocks.
Solution: There needs to be a gap between the call of the hook and the assertion, in order to give room to onCompleted() for getting executed. Add the async keyword before the test case and add a delay after the render(), like this:
await act(() => new Promise((resolve) => setTimeout(resolve, 2000)));
This can also be added when useLazyQuery or useMutation is called (say on the click of a button).
Although this is kind of a miss from the devs end, I feel this should be highlighted somewhere in the documentation for MockedProvider.

Resources