React-query cache doesn't persist on page refresh - reactjs

I would like to set a 24 hours cache once a useQuery request has succeeded.
But as soon as I refresh the page, the cache is gone. I see it because I console.log a message each time the route is hit on my server.
How to prevent this behaviour and implement a real cache?
Here is the code:
import { useQuery } from "react-query";
import { api } from "./config";
const _getUser = async () => {
try {
const res = api.get("/get-user");
return res;
} catch (err) {
return err;
}
};
export const getUser = () => {
const { data } = useQuery("contact", () => _getUser(), {
cacheTime: 1000 * 60 * 60 * 24,
});
return { user: data && data.data };
};
// then in the component:
const { user } = getUser();
return (
<div >
hello {user?.name}
</div>
I've also tried to replace cacheTime by staleTime.

if you reload the browser, the cache is gone because the cache lives in-memory. If you want a persistent cache, you can try out the (experimental) persistQueryClient plugin: https://react-query.tanstack.com/plugins/persistQueryClient

React query has now an experimental feature for persisting stuff on localStorage.
Nonetheless, I preferred using a custom hook, to make useQuery more robust and to persist stuff in localSTorage.
Here is my custom hook:
import { isSameDay } from "date-fns";
import { useEffect, useRef } from "react";
import { useBeforeunload } from "react-beforeunload";
import { useQuery, useQueryClient } from "react-query";
import { store as reduxStore } from "../../redux/store/store";
const LOCAL_STORAGE_CACHE_EXPIRY_TIME = 1000 * 60 * 60 * 23; // 23h
const divider = "---$---";
const defaultOptions = {
persist: true, // set to false not to cache stuff in localStorage
useLocation: true, // this will add the current location pathname to the component, to make the query keys more specific. disable if the same component is used on different pages and needs the same data
persistFor: LOCAL_STORAGE_CACHE_EXPIRY_TIME,
invalidateAfterMidnight: false, // probably you want this to be true for charts where the dates are visible. will overwrite persistFor, setting expiry time to today midnight
defaultTo: {},
};
const getLocalStorageCache = (dataId, invalidateAfterMidnight) => {
const data = localStorage.getItem(dataId);
if (!data) {
return;
}
try {
const parsedData = JSON.parse(data);
const today = new Date();
const expiryDate = new Date(Number(parsedData.expiryTime));
const expired =
today.getTime() - LOCAL_STORAGE_CACHE_EXPIRY_TIME >= expiryDate.getTime() ||
(invalidateAfterMidnight && !isSameDay(today, expiryDate));
if (expired || !parsedData?.data) {
// don't bother removing the item from localStorage, since it will be saved again with the new expiry time and date when the component is unmounted or the user leaves the page
return;
}
return parsedData.data;
} catch (e) {
console.log(`unable to parse local storage cache for ${dataId}`);
return undefined;
}
};
const saveToLocalStorage = (data, dataId) => {
try {
const wrapper = JSON.stringify({
expiryTime: new Date().getTime() + LOCAL_STORAGE_CACHE_EXPIRY_TIME,
data,
});
localStorage.setItem(dataId, wrapper);
} catch (e) {
console.log(
`Unable to save data in localStorage for ${dataId}. Most probably there is a function in the payload, and JSON.stringify failed`,
data,
e
);
}
};
const clearOtherCustomersData = globalCustomerId => {
// if we have data for other customers, delete it
Object.keys(localStorage).forEach(key => {
if (!key.includes(`preferences${divider}`)) {
const customerIdFromCacheKey = key.split(divider)[1];
if (customerIdFromCacheKey && customerIdFromCacheKey !== String(globalCustomerId)) {
localStorage.removeItem(key);
}
}
});
};
const customUseQuery = (queryKeys, getData, queryOptions) => {
const options = { ...defaultOptions, ...queryOptions };
const store = reduxStore.getState();
const globalCustomerId = options.withRealCustomerId
? store.userDetails?.userDetails?.customerId
: store.globalCustomerId.id;
const queryClient = useQueryClient();
const queryKey = Array.isArray(queryKeys)
? [...queryKeys, globalCustomerId]
: [queryKeys, globalCustomerId];
if (options.useLocation) {
if (typeof queryKey[0] === "string") {
queryKey[0] = `${queryKey[0]}--path--${window.location.pathname}`;
} else {
try {
queryKey[0] = `${JSON.stringify(queryKey[0])}${window.location.pathname}`;
} catch (e) {
console.error(
"Unable to make query. Make sure you provide a string or array with first item string to useQuery",
e,
);
}
}
}
const queryId = `${queryKey.slice(0, queryKey.length - 1).join()}${divider}${globalCustomerId}`;
const placeholderData = useRef(
options.persist
? getLocalStorageCache(queryId, options.invalidateAfterMidnight) ||
options.placeholderData
: options.placeholderData,
);
const useCallback = useRef(false);
const afterInvalidationCallback = useRef(null);
const showRefetch = useRef(false);
const onSuccess = freshData => {
placeholderData.current = undefined;
showRefetch.current = false;
if (options.onSuccess) {
options.onSuccess(freshData);
}
if (useCallback.current && afterInvalidationCallback.current) {
afterInvalidationCallback.current(freshData);
useCallback.current = false;
afterInvalidationCallback.current = null;
}
if (options.persist) {
if(globalCustomerId){
saveToLocalStorage(freshData, queryId);
}
}
};
const data = useQuery(queryKey, getData, {
...options,
placeholderData: placeholderData.current,
onSuccess,
});
const save = () => {
if (options.persist && data?.data) {
saveToLocalStorage(data.data, queryId);
}
};
// if there are other items in localStorage with the same name and a different customerId, delete them
// to keep the localStorage clear
useBeforeunload(() => clearOtherCustomersData(globalCustomerId));
useEffect(() => {
return save;
}, []);
const invalidateQuery = callBack => {
if (callBack && typeof callBack === "function") {
useCallback.current = true;
afterInvalidationCallback.current = callBack;
} else if (callBack) {
console.error(
"Non function provided to invalidateQuery. Make sure you provide a function or a falsy value, such as undefined, null, false or 0",
);
}
showRefetch.current = true;
queryClient.invalidateQueries(queryKey);
};
const updateQuery = callBackOrNewValue => {
queryClient.setQueryData(queryKey, prev => {
const updatedData =
typeof callBackOrNewValue === "function"
? callBackOrNewValue(prev)
: callBackOrNewValue;
return updatedData;
});
};
return {
...data,
queryKey,
invalidateQuery,
data: data.data || options.defaultTo,
updateQuery,
isFetchingAfterCacheDataWasReturned:
data.isFetching &&
!placeholderData.current &&
!data.isLoading &&
showRefetch.current === true,
};
};
export default customUseQuery;
Some things are specific to my project, like the customerId.
I'm using onBeforeUnload to delete data not belonging to the current customer, but this project specific.
You don't need to copy paste all this, but I believe it's very handy to have a custom hook around useQuery, so you can increase its potential and do things like running a callback with fresh data after the previous data has been invalidated or returning the invalidateQuery/updateQuery functions, so you don't need to use useQueryClient when you want to invalidate/update a query.

Related

react-countdown is not reseting or re-rendering second time

What I am trying to do is to update the reset the countdown after changing the status.
There are three status that i am fetching from API .. future, live and expired
If API is returning future with a timestamp, this timestamp is the start_time of the auction, but if the status is live then the timestamp is the end_time of the auction.
So in the following code I am calling api in useEffect to fetch initial data pass to the Countdown and it works, but on 1st complete in handleRenderer i am checking its status and updating the auctionStatus while useEffect is checking the updates to recall API for new timestamp .. so far its working and 2nd timestamp showed up but it is stopped ... means not counting down time for 2nd time.
import React, { useEffect } from 'react';
import { atom, useAtom } from 'jotai';
import { startTimeAtom, auctionStatusAtom } from '../../atoms';
import { toLocalDateTime } from '../../utility';
import Countdown from 'react-countdown';
import { getCurrentAuctionStatus } from '../../services/api';
async function getAuctionStatus() {
let response = await getCurrentAuctionStatus(WpaReactUi.auction_id);
return await response.payload();
}
const Counter = () => {
// component states
const [startTime, setStartTime] = useAtom(startTimeAtom);
const [auctionStatus, setAuctionStatus] = useAtom(auctionStatusAtom);
useEffect(() => {
getAuctionStatus().then((response) => {
setAuctionStatus(response.status);
setStartTime(toLocalDateTime(response.end_time, WpaReactUi.time_zone));
});
}, [auctionStatus]);
//
const handleRenderer = ({ completed, formatted }) => {
if (completed) {
console.log("auction status now is:", auctionStatus);
setTimeout(() => {
if (auctionStatus === 'future') {
getAuctionStatus().then((response) => {
setAuctionStatus(response.status);
});
}
}, 2000)
}
return Object.keys(formatted).map((key) => {
return (
<div key={`${key}`} className={`countDown bordered ${key}-box`}>
<span className={`num item ${key}`}>{formatted[key]}</span>
<span>{key}</span>
</div>
);
});
};
console.log('starttime now:', startTime);
return (
startTime && (
<div className="bidAuctionCounterContainer">
<div className="bidAuctionCounterInner">
<Countdown
key={auctionStatus}
autoStart={true}
id="bidAuctioncounter"
date={startTime}
intervalDelay={0}
precision={3}
renderer={handleRenderer}
/>
</div>
</div>
)
);
};
export default Counter;
You use auctionStatus as a dependency for useEffect.
And when response.status is the same, the auctionStatus doesn't change, so your useEffect won't be called again.
For answering your comment on how to resolve the issue..
I am not sure of your logic but I'll explain by this simple example.
export function App() {
// set state to 'live' by default
const [auctionStatus, setAuctionStatus] = React.useState("live")
React.useEffect(() => {
console.log('hello')
changeState()
}, [auctionStatus])
function changeState() {
// This line won't result in calling your useEffect
// setAuctionStatus("live") // 'hello' will be printed one time only.
// You need to use a state value that won't be similar to the previous one.
setAuctionStatus("inactive") // useEffect will be called and 'hello' will be printed twice.
}
}
You can simply use a flag instead that will keep on changing from true to false like this:
const [flag, setFlag] = React.useState(true)
useEffect(() => {
// ..
}, [flag])
// And in handleRenderer
getAuctionStatus().then((response) => {
setFlag(!flag);
});
Have a look at the following useCountdown hook:
https://codepen.io/AdamMorsi/pen/eYMpxOQ
const DEFAULT_TIME_IN_SECONDS = 60;
const useCountdown = ({ initialCounter, callback }) => {
const _initialCounter = initialCounter ?? DEFAULT_TIME_IN_SECONDS,
[resume, setResume] = useState(0),
[counter, setCounter] = useState(_initialCounter),
initial = useRef(_initialCounter),
intervalRef = useRef(null),
[isPause, setIsPause] = useState(false),
isStopBtnDisabled = counter === 0,
isPauseBtnDisabled = isPause || counter === 0,
isResumeBtnDisabled = !isPause;
const stopCounter = useCallback(() => {
clearInterval(intervalRef.current);
setCounter(0);
setIsPause(false);
}, []);
const startCounter = useCallback(
(seconds = initial.current) => {
intervalRef.current = setInterval(() => {
const newCounter = seconds--;
if (newCounter >= 0) {
setCounter(newCounter);
callback && callback(newCounter);
} else {
stopCounter();
}
}, 1000);
},
[stopCounter]
);
const pauseCounter = () => {
setResume(counter);
setIsPause(true);
clearInterval(intervalRef.current);
};
const resumeCounter = () => {
setResume(0);
setIsPause(false);
};
const resetCounter = useCallback(() => {
if (intervalRef.current) {
stopCounter();
}
setCounter(initial.current);
startCounter(initial.current - 1);
}, [startCounter, stopCounter]);
useEffect(() => {
resetCounter();
}, [resetCounter]);
useEffect(() => {
return () => {
stopCounter();
};
}, [stopCounter]);
return [
counter,
resetCounter,
stopCounter,
pauseCounter,
resumeCounter,
isStopBtnDisabled,
isPauseBtnDisabled,
isResumeBtnDisabled,
];
};

How to solve a situation when a component calls setState inside useEffect but the dependencies changes on every render?

I have this component:
const updateUrl = (url: string) => history.replaceState(null, '', url);
// TODO: Rename this one to account transactions ATT: #dmuneras
const AccountStatement: FC = () => {
const location = useLocation();
const navigate = useNavigate();
const { virtual_account_number: accountNumber, '*': transactionPath } =
useParams();
const [pagination, setPagination] = useState<PaginatorProps>();
const [goingToInvidualTransaction, setGoingToInvidualTransaction] =
useState<boolean>(false);
const SINGLE_TRANSACTION_PATH_PREFIX = 'transactions/';
// TODO: This one feels fragile, just respecting what I found, but, we could
// investigate if we can jsut rely on the normal routing. ATT. #dmuneras
const transactionId = transactionPath?.replace(
SINGLE_TRANSACTION_PATH_PREFIX,
''
);
const isFirst = useIsFirstRender();
useEffect(() => {
setGoingToInvidualTransaction(!!transactionId);
}, [isFirst]);
const {
state,
queryParams,
dispatch,
reset,
setCursorAfter,
setCursorBefore
} = useLocalState({
cursorAfter: transactionId,
includeCursor: !!transactionId
});
const {
filters,
queryParams: globalQueryParams,
setDateRange
} = useGlobalFilters();
useUpdateEffect(() => {
updateUrl(
`${location.pathname}?${prepareSearchParams(location.search, {
...queryParams,
...globalQueryParams
}).toString()}`
);
}, [transactionId, queryParams]);
useUpdateEffect(() => dispatch(reset()), [globalQueryParams]);
const account_number = accountNumber;
const requestParams = accountsStateToParams({
account_number,
...state,
...filters
});
const { data, isFetching, error, isSuccess } =
useFetchAccountStatementQuery(requestParams);
const virtualAccountTransactions = data && data.data ? data.data : [];
const nextPage = () => {
dispatch(setCursorAfter(data.meta.cursor_next));
};
const prevPage = () => {
dispatch(setCursorBefore(data.meta.cursor_prev));
};
const onRowClick = (_event: React.MouseEvent<HTMLElement>, rowData: any) => {
if (rowData.reference) {
if (rowData.id == transactionId) {
navigate('.');
} else {
const queryParams = prepareSearchParams('', {
reference: rowData.reference,
type: rowData.entry_type,
...globalQueryParams
});
navigate(
`${SINGLE_TRANSACTION_PATH_PREFIX}${rowData.id}?${queryParams}`
);
}
}
};
const checkIfDisabled = (rowData: TransactionData): boolean => {
return !rowData.reference;
};
useEffect(() => {
if (data?.meta) {
setPagination({
showPrev: data.meta.has_previous_page,
showNext: data.meta.has_next_page
});
}
}, [data?.meta]);
const showTransactionsTable: boolean =
Array.isArray(virtualAccountTransactions) && isSuccess && data?.data;
const onTransactionSourceLoaded = (
transactionSourceData: PayoutDetailData
) => {
const isIncludedInPage: boolean = virtualAccountTransactions.some(
(transaction: TransactionData) => {
if (transactionId) {
return transaction.id === parseInt(transactionId, 10);
}
return false;
}
);
if (!goingToInvidualTransaction || isIncludedInPage) {
return;
}
const fromDate = dayjs(transactionSourceData.timestamp);
const toDate = fromDate.clone().add(30, 'day');
setDateRange({
type: 'custom',
to: toDate.format(dateFormat),
from: fromDate.format(dateFormat)
});
setGoingToInvidualTransaction(false);
};
const fromDate = requestParams.created_after || dayjs().format('YYYY-MM-DD');
const toDate = requestParams.created_before || dayjs().format('YYYY-MM-DD');
const routes = [
{
index: true,
element: (
<BalanceWidget
virtualAccountNumber={account_number}
fromDate={fromDate}
toDate={toDate}
/>
)
},
{
path: `${SINGLE_TRANSACTION_PATH_PREFIX}:transaction_id`,
element: (
<TransactionDetails
onTransactionSourceLoaded={onTransactionSourceLoaded}
/>
)
}
];
return (........
I get this error: Warning: Maximum update depth exceeded. This can happen when a component calls setState inside useEffect, but useEffect either doesn't have a dependency array, or one of the dependencies changes on every render.
The useEffect where the issue is, it is this one:
useEffect(() => {
if (data?.meta) {
setPagination({
showPrev: data.meta.has_previous_page,
showNext: data.meta.has_next_page
});
}
}, [data?.meta]);
Considering previous answers, would the solution be to make sure I return a new object each time? But I am not sure what would be the best approach. Any clues ?
did you want the useEffect to start every changes of 'data?.meta' ?
Without reading all the code, I believe the data.meta object changes on every render. There is a way to change the useEffect to narrow done its execution conditions:
useEffect(() => {
if (data?.meta) {
setPagination({
showPrev: data.meta.has_previous_page,
showNext: data.meta.has_next_page
});
}
}, [!data?.meta, data?.meta?.has_previous_page, data?.meta?.has_next_page]);
Please note the ! before data.?.meta which makes the hook test only for presence or absence of the object, since your code doesn't need more than that information.

getting localStorage data so that I can pass it to apollo query variables but skip query if the variable is empty

I save product ids in a localstorage to be used in recently viewed component.
My Recently viewed component is below
import { useQuery } from "#apollo/client";
import { getRecentlyViewedProductArr } from "#gb-utils/product/product"
import { RECENTLY_VIEWED } from "#gb-utils/queries/product";
import { useEffect, useState } from "react";
export default function RecentlyViewed() {
const [recentIds, setRecentIds] = useState([])
const { loading, error, data } = useQuery(RECENTLY_VIEWED, {
variables: { ids: recentIds }
})
useEffect(() => {
setRecentIds(getRecentlyViewedProductArr())
}, []);
if (loading) {
return 'Loading';
}
if (error) {
return error.message
}
return (
<div>{JSON.stringify(data)}</div>
)
}
My question is about how I use get the product from wp-graphql using userQuery with the local storage.
Currently I am defining a state to store product ids and on mount of the RecentlyViewed component I update the recentIds by getting the data from localstorage. Is there a better way of doing the code above because I feel like it fetching data from wp without or empty recentIds is a problem.
helper.js
export const addRecentlyViewedProduct = (productId) => {
let recent = getRecentlyViewedProduct()
if (isEmpty(recent)) {
// If recently viewed is empty then we just save the product id
saveRecentlyViewedProduct(productId)
return getRecentlyViewedProduct()
}
// if not empty then we check if product id exist and remove
if (recent.includes(productId)) {
recent = recent.replace(productId, '')
}
// Since we remove the product then we Add the product id again
recent = "".concat(productId, '|', recent)
recent = recent.replace('||', '|');
recent = recent.startsWith('|') ? recent.substring(1) : recent
recent = recent.endsWith('|') ? recent.substring(0, recent.length - 1) : recent;
const limit = 5;
let recentProductIds = recent.split('|')
if (recentProductIds.length > limit) {
recentProductIds = recentProductIds.slice(0, limit)
}
saveRecentlyViewedProduct(recentProductIds.join('|'))
return getRecentlyViewedProduct()
}
export const saveRecentlyViewedProduct = (value) => {
return localStorage.setItem('woo-next-recent-product', value);
}
export const getRecentlyViewedProduct = () => {
const recentProductsStr = localStorage.getItem('woo-next-recent-product')
return recentProductsStr
}
export const getRecentlyViewedProductArr = () => {
const recentProductsStr = getRecentlyViewedProduct()
if (isEmpty(recentProductsStr)) {
return ''
}
let recentProductIds = recentProductsStr.split('|')
return Array.from(recentProductIds.slice(1), Number); // get the 2nd to the last element
}
You can use the skip option from the useQuery API: https://www.apollographql.com/docs/react/data/queries/#skip
const [recentIds, setRecentIds] = useState([])
const { loading, error, data } = useQuery(RECENTLY_VIEWED, {
variables: { ids: recentIds },
skip: recentIds.length === 0
})
useEffect(() => {
setRecentIds(getRecentlyViewedProductArr())
}, []);

POST Error 404(localhost:3000) || GET Error ERR_CONNECTION_REFUSED(localhost:5000) with socket.io/nodemoon/react

I am currently following a tutorial from webDevSimplified
youtube : https://www.youtube.com/watch?v=tBr-PybP_9c
github repo : https://github.com/WebDevSimplified/Whatsapp-Clone
I have compared my code with the repo countless times and also read socket.io's documentation but to no avail.
There are already fair a few threads out there with a similar issue but many have been left unanswered or simply have a completely different setup.
I have used yarn package manager for the project instead of npm as shown in the tutorial but I doubt this is the culprit. I have all required dependencies installed.
Let's begin with server.js
const io = require('socket.io')(5000) // I changed it to 3000 but just got a different Error(see title)
io.on('connection', socket => {
const id = socket.handshake.query.id
socket.join(id)
socket.on('send-message', ({ recipients, text }) => {
recipients.forEach(recipient => {
const newRecipients = recipients.filter(r => r !== recipient)
newRecipients.push(id)
socket.broadcast.to(recipient).emit('receive-message', {
recipients: newRecipients, sender: id, text
})
})
})
})
In this app we used useContext to provide the socket for our components.
Here the socketProvider.js
import React, { useContext, useEffect, useState } from "react";
import io from "socket.io-client";
const SocketContext = React.createContext();
export function useSocket() {
return useContext(SocketContext);
}
export function SocketProvider({ id, children }) {
const [socket, setSocket] = useState();
//console.log(socket)
useEffect(() => {
const newSocket = io("http://localhost:5000", { query: { id } });// I changed the host here as well to 3000 but I either get a POST or a GET error respectively
setSocket(newSocket);
return () => newSocket.close();
}, [id]);
return (
<SocketContext.Provider value={socket}>{children}</SocketContext.Provider>
);
}
Which is then nested in another contextProvider aka ConversationsProvider.js
import React, { useContext, useState, useEffect, useCallback } from "react";
import useLocalStorage from "../hooks/useLocalStorage";
import { useContacts } from "./ContactsProvider";
import { useSocket } from "./SocketProvider";
const ConversationsContext = React.createContext();
export function useConversations() {
return useContext(ConversationsContext);
}
export function ConversationsProvider({ id, children }) {
const [conversations, setConversations] = useLocalStorage("conversations",[]);
const [selectedConversationIndex, setSelectedConversationIndex] = useState(0);
const { contacts } = useContacts();
const socket = useSocket();
function createConversation(recipients) {
setConversations((prevConversations) => {
return [...prevConversations, { recipients, messages: [] }];
});
}
const addMessageToConversation = useCallback(({ recipients, text, sender }) => {
setConversations((prevConversations) => {
let madeChange = false;
const newMessage = { sender, text };
const newConversations = prevConversations.map((conversation) => {
if (arrayEquality(conversation.recipients, recipients)) {
madeChange = true;
return {
...conversation,
messages: [...conversation.messages, newMessage],
};
}
return conversation;
});
if (madeChange) {
return newConversations;
} else {
return [...prevConversations, { recipients, messages: [newMessage] }];
}
});
}, [setConversations])
useEffect(() => {
if(socket == null) return
socket.on('receive-message', addMessageToConversation)
return () => socket.off('receive-message')
},[socket, addMessageToConversation])
function sendMessage(recipients, text) {
socket.emit('send-message', { recipients, text })
addMessageToConversation({ recipients, text, sender: id });
}
const formattedConversations = conversations.map((conversation, index) => {
const recipients = conversation.recipients.map((recipient) => {
const contact = contacts.find((contact) => {
return contact.id === recipient;
});
const name = (contact && contact.name) || recipient;
return { id: recipient, name };
});
const messages = conversation.messages.map(message => {
const contact = contacts.find((contact) => {
return contact.id === message.sender;
})
const name = (contact && contact.name) || message.sender;
const fromMe = id === message.sender
return { ...message, senderName: name, fromMe }
})
const selected = index === selectedConversationIndex;
return { ...conversation, messages, recipients, selected };
});
const value = {
conversations: formattedConversations,
selectedConversation: formattedConversations[selectedConversationIndex],
sendMessage,
selectConversationIndex: setSelectedConversationIndex,
createConversation,
};
return (
<ConversationsContext.Provider value={value}>
{children}
</ConversationsContext.Provider>
);
}
function arrayEquality(a, b) {
if (a.length !== b.length) return false;
a.sort();
b.sort();
return a.every((element, index) => {
return element === b[index];
});
}
I had no prior experience with the websocket.API / socket.io and I have not yet fully understood the workflow. I hope someone might see an error I didn't see/noticed so far.
I believe this react setup is slightly different from the norm as I couldn't find any similar setup online which might have been helpful in solving my issue.
If you feel it might be relevant check the master repo above. My local folder structure as well as the code is identical.
I will try another stack and see if I can get it running with node.js or try out the example from the docs meanwhile.
I found the the reason for the error. I simply missed to run the nodemon script in the server directory.

Apollo query not triggering useEffect

I need a refetch on an Apollo query to trigger useEffect. I'm implementing a filter bar that filters by a date period and a text search. My search submit button triggers the refetch on the query, and the data returned is a dependency of my useEffect. When the data is returned, if the search fields are populated, the filter should run. This works the first time I run a search, but when I reset with my reset function, the reset is triggered and the query gets 200 in Chrome and shows the data in Chrome dev tools, but useEffect is not triggered. Why is this?
//React
import React, {useState, useContext, useEffect, Fragment } from 'react'
//extra React libraries
//external libraries
import { useQuery, useMutation } from '#apollo/react-hooks'
//components
import AccountsGrid from '../../containers/AccountsGrid/AccountsGrid'
import SpinnerLoader from '../../components/SpinnerLoader/SpinnerLoader'
//styles
import styles from "./accounts.module.scss";
//contexts
import UserContext from '../../containers/contexts/UserContext/UserContext'
import AccountContext from '../../containers/contexts/AccountContext/AccountContext'
//constants
import {
GET_PARENT_ACCOUNT,
SET_ACCOUNTS,
SET_IS_ACTIVE,
SET_ACCOUNTS_LIMIT,
SET_ACCOUNTS_OFFSET,
SET_ACCOUNTS_TOTAL,
SET_ACCOUNTS_START_DATE,
SET_ACCOUNTS_END_DATE,
} from '../../containers/contexts/AccountContext/accountActionTypes'
import {
GET_ACCOUNT_USERS,
GET_ACCOUNTS,
TOTAL_ACCOUNTS,
} from '../../utils/constants/queries/accountQueries'
import {
UPDATE_ACCOUNT
} from '../../utils/constants/mutations/accountMutations'
import moment from 'moment';
function Accounts(props) {
const userContext = useContext(UserContext)
const accountContext = useContext(AccountContext)
const parentAccountID = userContext.userState.accountId
const [parentAccountIDs, setParentAccountIDs] = useState(null)
const [sortByField, setSortByField] = React.useState(null);
const [sortByType, setSortByType] = React.useState(null);
const [startDate, setStartDate] = React.useState(null);
const [endDate, setEndDate] = React.useState(null);
const [datesValid, setDatesValid] = React.useState(true);
const [searchText, setSearchText] = React.useState("");
const {
loading: loadingAccountUsers,
error: errorAccountUsers,
data: dataAccountUsers,
refetch: refetchAccountUsers,
} = useQuery(GET_ACCOUNT_USERS, {
variables: {
accountId: parentAccountID
}
})
const {
loading: loadingAccounts,
error: errorAccounts,
data: dataAccounts,
refetch: refetchAccounts
} = useQuery(GET_ACCOUNTS, {
variables: {
parentIds: parentAccountIDs,
offset: accountContext.accountState.data.accountsOffset,
limit: accountContext.accountState.data.accountsLimit
}
})
const {
loading: loadingAccountsTotal,
error: errorAccountsTotal,
data: dataAccountsTotal,
refetch: refetchAccountsTotal
} = useQuery(TOTAL_ACCOUNTS)
const [
updateAccount,
{ loading, error, data: updateAccountData }
] = useMutation(UPDATE_ACCOUNT);
const setParentIDsHandler = (id) => {
setParentAccountIDs(String(id))
}
const setOffset = (offset, limit) => {
accountContext.accountDispatch({
type: SET_ACCOUNTS_OFFSET,
payload: {
offset: offset
}
})
accountContext.accountDispatch({
type: SET_ACCOUNTS_LIMIT,
payload: {
limit: limit
}
})
}
const deactivateUser = row => {
updateAccount({
variables: {
account: {
id: row.id,
isActive: !row.isActive
}
}
})
accountContext.accountDispatch({type: SET_IS_ACTIVE, payload: row.id})
}
const handleRequestSort = (sortType, sortBy) => {
sortRow(sortBy)
setSortByType(sortType)
}
const sortRow = (sortBy) => {
switch(sortBy) {
case 'Contact':
setSortByField('email')
break
case 'First Name':
setSortByField('firstName')
break
case 'Last Name':
setSortByField('lastName')
break
case 'Join Date':
setSortByField('dateJoined')
break
}
}
const setDates = (dates) => {
console.log("SETTING DATES", dates)
if ("startDate" === Object.keys(dates)[0]) {
setStartDate(dates.startDate)
} else {
setEndDate(dates.endDate)
}
}
const checkDatesValid = () => {
if (startDate && endDate) {
if (startDate <= endDate) {
return setDatesValid(true)
}
}
return setDatesValid(false)
}
const clearDates = () => {
console.log("CLEARING")
setDatesValid(true)
setStartDate(null)
setEndDate(null)
setSearchText(null)
accountContext.accountDispatch({type: SET_ACCOUNTS_START_DATE, payload: {startDate: null}})
accountContext.accountDispatch({type: SET_ACCOUNTS_END_DATE, payload: {endDate: null}})
return setDatesValid(true)
}
const searchByChars = (text) => {
console.log("SEARCH TEXT", text)
setSearchText(text)
resetAccounts()
}
const resetAccounts = () => {
console.log("RESET ACCOUNTS TRIGGERED")
refetchAccounts({
variables: {
parentIds: parentAccountIDs,
offset: accountContext.accountState.data.accountsOffset,
limit: accountContext.accountState.data.accountsLimit
}
})
console.log("LOADING", loadingAccounts)
console.log("ERROR", errorAccounts)
}
const filterText = (textRows) => {
let newTextRows = []
for (let row of textRows) {
Object.entries(row['users'][0]).forEach(([key, val]) => {
if (String(val).includes(String(searchText))) {
if (!(newTextRows.includes(row))) {
newTextRows.push(row)
}
}
});
}
accountContext.accountDispatch({type: SET_ACCOUNTS, payload: {accounts: newTextRows}})
}
const filterDates = (dateRows) => {
let newDateRows = []
for (let row of dateRows) {
if (datesValid) {
const _date = moment(row.users[0]['dateJoined'])
const sdate = moment(startDate)
const edate = moment(endDate)
if (_date.isBetween(sdate, edate, null, '[]')) {
if (!(newDateRows.includes(row))) {
newDateRows.push(row)
}
}
}
}
accountContext.accountDispatch({type: SET_ACCOUNTS, payload: {accounts: newDateRows}})
}
useEffect(() => {
if (sortByField && sortByType) {
const newRows = accountContext.accountState.data.accounts.sort((a, b) => {
const compareA = a.users[0][sortByField]
const compareB = b.users[0][sortByField]
if (compareA || compareB) {
if ("DESC" === sortByType) {
let comparison = 0;
if (compareA > compareB) {
comparison = 1;
} else if (compareA < compareB) {
comparison = -1;
}
return comparison;
} else {
let comparison = 0;
if (compareA < compareB) {
comparison = 1;
} else if (compareA > compareB) {
comparison = -1;
}
return comparison;
}
}
})
accountContext.accountDispatch({type: SET_ACCOUNTS, payload: newRows})
}
if (dataAccountsTotal) {
accountContext.accountDispatch({type: SET_ACCOUNTS_TOTAL, payload: dataAccountsTotal})
}
if (dataAccountUsers) {
setParentIDsHandler(dataAccountUsers.accountUsers[0].account.id)
accountContext.accountDispatch({type: GET_PARENT_ACCOUNT, payload: dataAccountUsers})
}
if (dataAccounts) {
console.log("NEW DATA", searchText, startDate, endDate, datesValid)
console.log("SEARCH TEXT", searchText)
console.log("START DATE", startDate)
console.log("END DATE", endDate)
accountContext.accountDispatch({type: SET_ACCOUNTS, payload: dataAccounts})
console.log("DATES VALID", datesValid)
if (startDate
&& endDate && datesValid) {
console.log("FILTER BY DATES")
filterDates(accountContext.accountState.data.accounts)
}
if (searchText) {
filterText(dataAccounts)
}
}
}, [
loadingAccounts,
dataAccounts,
dataAccountsTotal,
dataAccountUsers,
parentAccountIDs,
updateAccountData,
sortByField,
sortByType,
searchText
])
return (
<Fragment>
{
accountContext.accountState.data.accounts &&
!loadingAccountUsers &&
!errorAccountUsers &&
!loadingAccounts &&
!errorAccounts &&
parentAccountIDs &&
accountContext.accountState.data.accountUsers
? <AccountsGrid
setOffset={setOffset}
currentStartDate={startDate}
currentEndDate={endDate}
searchText={searchByChars}
datesValid={datesValid}
resetAccounts={resetAccounts}
setDates={setDates}
clearDates={clearDates}
deactivateUser={deactivateUser}
handleRequestSort={handleRequestSort}
accountRows={accountContext.accountState.data.accounts}
/> : <SpinnerLoader />}
</Fragment>
)
}
export default Accounts
I realize this is an old question, but I had a similar issue and arrived here to an unanswered question. I would like to share what I have learned about useEffect hook with Apollo Query or in any other use case, in the event it helps someone else.
useEffect hook does not actively "watch" and only triggers under select circumstances. Data loading from a graphql query is not one of the triggers. useEffect will run :
After re-rendering of the layout when the component mounts
If the state changes of the component
If the props of the component change
When the component unmounts
Please check out this amazing article for more details.
Also if the variable is placed in the dependency array at the end of the useEffect hook, by design, it will NOT run if there are no changes to the variable.
So in order to trigger useEffect after data is loaded from an Apollo query you would have to make one of those conditions happen when the data loads.
It was much easier in my case to simply use the data after loading because trying to sync setState which is async with data loading which is async created some varied and unpredictable results.
It did help to use the parent class and pass in a variable as a prop which would trigger useEffect to setState when it changed. The variable was used in the actual Apollo query so it made sense to the overall design.
Here is an example:
export const ResultsGrid = ( queryTerm ) => {
const [searchTerm, setSearchTerm] = useState('')
const { data, error, loading } = useQuery(GET_RESULTS_QUERY, {variables: { searchTerm }, })
useEffect(() => {setSearchTerm(queryTerm)}
,[queryTerm]) //useEffect will run only when queryTerm changes
if (loading) return <p>Loading data from query...</p>
if (error)
return (
<React.Fragment>
<p>Sorry, unable to fetch results. </p>
<button onClick={() => refetch}>Please try again!</button>
{console.log("error", error)}
</React.Fragment>
)
console.log('data:', data)
console.log('loading:', loading)
const { results } = data
return (
//display data results here
)
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.4.1/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.4.1/umd/react-dom.production.min.js"></script>
I hope this is helpful and answers the question.

Resources