Infinite scroll updates data twice - reactjs

I have a lazy loaded table (infinite scroll). Unfortunatelly, I don't know why but it updates on data twice when I scroll to the bottom. So two queries to graphql are made instead of one. If I remove data from fetchMore dependency it works fine (but then, eslint throws warning so it is not a solution). Also when I remove scroll and replace it by manual button and click for fetch also works good, so I dont know if problem is in query or maybe in WithInfiniteScroll
const LIMIT = 10;
const updateQuery = (
previousQueryResult: GetStaffQuery,
options: {
fetchMoreResult?: GetStaffQuery;
variables?: GetStaffQueryVariables;
}
): GetStaffQuery => {
const {fetchMoreResult} = options;
const currentNodes = previousQueryResult.staff.nodes || [];
const newNodes = fetchMoreResult?.staff.nodes || [];
const newResult = {
staff: {
...fetchMoreResult?.staff,
nodes: [...currentNodes, ...newNodes],
},
};
return newResult;
};
export const useUsersList = () => {
const [isInitialFetching, setIsInitialFetching] = useState(true);
const {data, fetchMore: handleFetchMore, loading} = useGetStaffQuery({
variables: {limit: LIMIT, nextToken: null},
onCompleted: () => {
setIsInitialFetching(false);
},
});
useEffect(() => {
debugger; //triggered twice when scrolled to the bottom
}, [data]);
const fetchMore = useCallback(() => {
const nextToken = data?.staff.nextToken || null;
if (nextToken && !loading && !isInitialFetching) {
const queryVariables: GetStaffQueryVariables = {
limit: LIMIT,
nextToken,
};
handleFetchMore({variables: queryVariables, updateQuery});
}
}, [data, handleFetchMore, isInitialFetching, loading]);
return {
isLoading: loading,
canLoadMore: Boolean(data?.staff.nextToken && !loading) || false,
fetchMore,
users: data?.staff.nodes || [],
};
};
Infinite scroll:
import React, {useEffect, ReactNode} from 'react';
import {useInView} from 'react-intersection-observer';
type PropTypes = {
children?: ReactNode;
canLoadMore: boolean;
onLoadMore: () => unknown;
};
const rootMargin = '400px';
export const WithInfiniteScroll = ({
children,
canLoadMore,
onLoadMore,
}: PropTypes) => {
const [ref, isElementInViewport] = useInView({
rootMargin,
skip: !canLoadMore,
});
useEffect(() => {
if (canLoadMore && isElementInViewport) {
onLoadMore();
}
}, [canLoadMore, isElementInViewport, onLoadMore]);
return (
<>
{children}
<div ref={ref} />
</>
);
};
and some draft of component:
<WithInfiniteScroll canLoadMore={canLoadMore} onLoadMore={onLoadMore}>
<div>
{users.map(user => <span>{user.id}</span>)}
</div>
</WithInfiniteScroll>

I had the same issue just now! In my case the problem was that in my onLoad method I forgot "await" to fetch the data with my async service method.. So the data would not have arrived already when done() was executed (and therefore a subsequent get data call to the server)

Related

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.

Lexicaljs receive editor state json and text content using debounce in react project

Requirement
I have a requirement to get the editor state in JSON format as well as the text content of the editor. In addition, I want to receive these values in the debounced way.
I wanted to get these values (as debounced) because I wanted to send them to my server.
Dependencies
"react": "^18.2.0",
"lexical": "^0.3.8",
"#lexical/react": "^0.3.8",
You don't need to touch any of Lexical's internals for this; a custom hook that reads and "stashes" the editor state into a ref and sets up a debounced callback (via use-debounce here, but you can use whatever implementation you like) is enough.
getEditorState is in charge of converting the editor state into whichever format you want to send over the wire. It's always called within editorState.read().
function useDebouncedLexicalOnChange<T>(
getEditorState: (editorState: EditorState) => T,
callback: (value: T) => void,
delay: number
) {
const lastPayloadRef = React.useRef<T | null>(null);
const callbackRef = React.useRef<(arg: T) => void | null>(callback);
React.useEffect(() => {
callbackRef.current = callback;
}, [callback]);
const callCallbackWithLastPayload = React.useCallback(() => {
if (lastPayloadRef.current) {
callbackRef.current?.(lastPayloadRef.current);
}
}, []);
const call = useDebouncedCallback(callCallbackWithLastPayload, delay);
const onChange = React.useCallback(
(editorState) => {
editorState.read(() => {
lastPayloadRef.current = getEditorState(editorState);
call();
});
},
[call, getEditorState]
);
return onChange;
}
// ...
const getEditorState = (editorState: EditorState) => ({
text: $getRoot().getTextContent(false),
stateJson: JSON.stringify(editorState)
});
function App() {
const debouncedOnChange = React.useCallback((value) => {
console.log(new Date(), value);
// TODO: send to server
}, []);
const onChange = useDebouncedLexicalOnChange(
getEditorState,
debouncedOnChange,
1000
);
// ...
<OnChangePlugin onChange={onChange} />
}
Code
File: onChangeDebouce.tsx
import {$getRoot} from "lexical";
import { useLexicalComposerContext } from "#lexical/react/LexicalComposerContext";
import React from "react";
const CAN_USE_DOM = typeof window !== 'undefined' && typeof window.document !== 'undefined' && typeof window.document.createElement !== 'undefined';
const useLayoutEffectImpl = CAN_USE_DOM ? React.useLayoutEffect : React.useEffect;
var useLayoutEffect = useLayoutEffectImpl;
type onChangeFunction = (editorStateJson: string, editorText: string) => void;
export const OnChangeDebounce: React.FC<{
ignoreInitialChange?: boolean;
ignoreSelectionChange?: boolean;
onChange: onChangeFunction;
wait?: number
}> = ({ ignoreInitialChange= true, ignoreSelectionChange = false, onChange, wait= 167 }) => {
const [editor] = useLexicalComposerContext();
let timerId: NodeJS.Timeout | null = null;
useLayoutEffect(() => {
return editor.registerUpdateListener(({
editorState,
dirtyElements,
dirtyLeaves,
prevEditorState
}) => {
if (ignoreSelectionChange && dirtyElements.size === 0 && dirtyLeaves.size === 0) {
return;
}
if (ignoreInitialChange && prevEditorState.isEmpty()) {
return;
}
if(timerId === null) {
timerId = setTimeout(() => {
editorState.read(() => {
const root = $getRoot();
onChange(JSON.stringify(editorState), root.getTextContent());
})
}, wait);
} else {
clearTimeout(timerId);
timerId = setTimeout(() => {
editorState.read(() => {
const root = $getRoot();
onChange(JSON.stringify(editorState), root.getTextContent());
});
}, wait);
}
});
}, [editor, ignoreInitialChange, ignoreSelectionChange, onChange]);
return null;
}
This is the code for the plugin and it is inspired (or copied) from OnChangePlugin of lexical
Since, lexical is in early development the implementation of OnChangePlugin might change. And in fact, there is one more parameter added as of version 0.3.8. You can check the latest code at github.
The only thing I have added is calling onChange function in timer logic.
ie.
if(timerId === null) {
timerId = setTimeout(() => {
editorState.read(() => {
const root = $getRoot();
onChange(JSON.stringify(editorState), root.getTextContent());
})
}, wait);
} else {
clearTimeout(timerId);
timerId = setTimeout(() => {
editorState.read(() => {
const root = $getRoot();
onChange(JSON.stringify(editorState), root.getTextContent());
});
}, wait);
}
If you are new to lexical, then you have to use declare this plugin as a child of lexical composer, something like this.
File: RichEditor.tsx
<LexicalComposer initialConfig={getRichTextConfig(namespace)}>
<div className="editor-shell lg:m-2" ref={scrollRef}>
<div className="editor-container">
{/* your other plugins */}
<RichTextPlugin
contentEditable={<ContentEditable
className={"ContentEditable__root"} />
}
placeholder={<Placeholder text={placeHolderText} />}
/>
<OnChangeDebounce onChange={onChange} />
</div>
</div>
</LexicalComposer>
In this code, as you can see I have passed the onChange function as a prop and you can also pass wait in milliseconds like this.
<OnChangeDebounce onChange={onChange} wait={1000}/>
Now the last bit is the implementation of onChange function, which is pretty straightforward
const onChange = (editorStateJson:string, editorText:string) => {
console.log("editorStateJson:", editorStateJson);
console.log("editorText:", editorText);
// send data to a server or to your data store (eg. redux)
};
Finally
Thanks to Meta and the lexical team for open sourcing this library. And lastly, the code I have provided works for me, I am no expert, feel free to comment to suggest an improvement.

UseEffect doesn't trigger on second change, but trigger twice on launch.(React hooks and apollo-graphql hooks)

useEffect doesn't trigger on second change, but triggers twice on launch (React hooks and apollo-graphql hooks).
In console.logs I described when the changes are triggered and when not.
I don't have any more clue to add.
Here's my page (Next.js pages)
import React, { useEffect, useState } from 'react'
import Calendar from '../components/Calendar';
import { useHallsQuery } from '../generated/graphql';
const schedule = () => {
const { data, loading, error, refetch:refetchSessions } = useHallsQuery();
const [sessions, setSessions] = useState([] as any);
const [owners, setOwners] = useState([] as any);
useEffect(() => {
console.log('On launch trigger twice, on first change trigger once, on second doesn't trigger and later trigger correctly, but it's one change delay');
if(loading === false && data){
const colors: any = [
'#FF3333',
'#3333FF',
'#FFFF33',
'#33FF33',
'#33FFFF',
'#9933FF',
'#FF9933',
'#FF33FF',
'#FF3399',
'#A0A0A0'
];
let pushSessions:any = [];
let owners:any = [];
data?.halls?.map(({sessions, ...o}, index) =>{
owners.push({id:o.id,
text:o.name,
color: colors[index%10]});
sessions.map((session:any) => {
pushSessions.push({...session,
ownerId: o.id});
})
})
setSessions(pushSessions);
setOwners(owners);
}
}, [loading, data])
if (loading) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
return (
<div>
<Calendar sessions={sessions} owners={owners} refetchSessions={refetchSessions} />
</div>
)
}
export default schedule
and my component part where I get props and trigger refetchSessions.
const Calendar = (props: any) => {
let { sessions, owners, refetchSessions } = props;
const [moveSession] = useMoveSessionMutation();
...
const commitChanges = ({ added, changed, deleted }: any) => {
if (added) {
//
}
if (changed) {
console.log('trigger on all changes! Correct')
const id = Object.keys(changed)[0];
moveSession({variables:{
input: {
id: parseInt(id),
...changed[id]
}
}, refetchQueries: refetchSessions
})
}
if (deleted !== undefined) {
//
}
};
return (
// Some material-ui components and #devexpress/dx-react-scheduler-material-ui components in which commitChanges function is handled
)
export default Calendar;
Hook was generated with graphql-codegen(generated/graphql.tsx):
export function useHallsQuery(baseOptions?: Apollo.QueryHookOptions<HallsQuery, HallsQueryVariables>) {
return Apollo.useQuery<HallsQuery, HallsQueryVariables>(HallsDocument, baseOptions);
}
export function useHallsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<HallsQuery, HallsQueryVariables>) {
return Apollo.useLazyQuery<HallsQuery, HallsQueryVariables>(HallsDocument, baseOptions);
}
export type HallsQueryHookResult = ReturnType<typeof useHallsQuery>;
and here's the schema(graphql/hall.graphql):
query Halls {
halls {
id
name
sessions{
id
title
startDate
endDate
}
}
}

Component rerenders with wrong redux state?

I have a very weird bug that I'm trying to understand for 1.5 days now. The problem with this bug is, that it is very hard to show it without showing around 2000 lines of code - I tried rebuilding a simple example in a codesandbox but couldn't reproduce the bug.
The bug can be easily described, though:
I have a parent component A, and a child component B. Both are connected to the same redux store and subscribed to a reducer called active. Both components print the exact same activeQuestion state property. Both components are connected to the redux store individually via connect()
I dispatch an action SET_ACTIVE_QUESTION and the components rerender (I'm not sure why each re-render happens) and component B now has the updated state from the store and component A doesn't ... and I can't seem to figure out why that is.
The real application is fairly big but there are a couple of weird things that I observed:
The bug disappears when I subscribe the parent component of A to the active state (Component A is subscribed itself).
The action to change the active question is qued before it is fired with setTimeout(() => doAction(), 0). If I remove the setTimeout the bug disappears.
Here is why I think this question is relevant even without code: How is it even possible that an action is dispatched in the redux store (the first console log is directly from the reducer) and the wrong state is displayed on a subsequent render? I'm not sure how this could even be possible unless its a closure or something.
Update (mapStateToProps) functions:
Component A (wrong state):
const mapStateToProps = (state: AppState) => ({
active: state.active,
answerList: state.answerList,
surveyNotifications: state.surveyNotifications,
activeDependencies: state.activeDependencies,
});
Component B (right state):
const mapStateToProps = (state: AppState) => ({
surveyNotifications: state.surveyNotifications,
active: state.active,
answerList: state.answerList,
activeDependencies: state.activeDependencies,
});
Update:
The state transition is triggered by component B (correct state) with this function:
const goToNextQuestionWithTransition = (
where: string,
shouldPerformValidation?: boolean
) => {
setInState(false);
setTimeout(() => {
props.goToQuestion(where, shouldPerformValidation);
}, 200);
};
Removing the setTimeout removes the bug (but I don't know why)
Update (show reducer):
export const INITIAL_SATE = {
activeQuestionUUID: '',
...
};
export default function (state = INITIAL_SATE, action) {
switch (action.type) {
case actionTypes.SET_ACTIVE_QUESTION:
console.log('Action from reducer', action)
return { ...state, activeQuestionUUID: action.payload };
...
default:
return {...state};
}
}
Update
Component A - correct state
const Survey: React.FC<IProps> = (props) => {
const {
survey,
survey: { tenantModuleSet },
} = props;
const [isComplete, setIsComplete] = React.useState(false);
const classes = useStyles();
const surveyUtils = useSurveyUtils();
console.log('Log from component A', props.active.activeQuestionUUID)
React.useEffect(() => {
const firstModule = tenantModuleSet[0];
if (firstModule) {
props.setActiveModule(firstModule.uuid);
} else {
setIsComplete(true);
}
}, []);
const orderedLists: IOrderedLists = useMemo(() => {
let orderedQuestionList: Array<string> = [];
let orderedModuleList: Array<string> = [];
tenantModuleSet.forEach((module) => {
orderedModuleList.push(module.uuid);
module.tenantQuestionSet.forEach((question) => {
orderedQuestionList.push(question.uuid);
});
});
return {
questions: orderedQuestionList,
modules: orderedModuleList,
};
}, [survey]);
const validateQuestion = (question: IQuestion) => {
...
};
const findModuleForQuestion = (questionUUID: string) => {
...
};
const { setActiveQuestion, setActiveModule, active } = props;
const { activeQuestionUUID, activeModuleUUID } = props.active;
const currentQuestionIndex = orderedLists.questions.indexOf(
activeQuestionUUID
);
const currentModuleIndex = orderedLists.modules.indexOf(activeModuleUUID);
const currentModule = props.survey.tenantModuleSet.filter(
(module) => module.uuid === active.activeModuleUUID
)[0];
if (!currentModule) return null;
const currentQuestion = currentModule.tenantQuestionSet.filter(
(question) => question.uuid === activeQuestionUUID
)[0];
const handleActiveSurveyScrollDirection = (destination: string) => {
...
};
const isQuestionLastInModule = ...
const moveToNextQuestion = (modules: string[], questions: string[]) => {
if (isQuestionLastInModule) {
if (currentModule.uuid === modules[modules.length - 1]) {
props.setActiveSurveyView("form");
} else {
setActiveQuestion("");
setActiveModule(modules[currentModuleIndex + 1]);
}
} else {
console.log('this is the move function')
setActiveQuestion(questions[currentQuestionIndex + 1]);
}
};
const goToQuestiton = (destination: string, useValidation = true) => {
....
moveToNextQuestion(modules, questions);
};
return (
<section className={classes.view}>
{isComplete ? (
<SurveyComplete />
) : (
<div className={classes.bodySection}>
<Module
// adding a key here is nessesary
// or the Module will not unmount when the module changes
key={currentModule.uuid}
module={currentModule}
survey={props.survey}
goToQuestion={goToQuestiton}
/>
</div>
)}
{!isComplete && (
<div className={classes.footerSection}>
<SurveyFooter
tenantModuleSet={props.survey.tenantModuleSet}
goToQuestion={goToQuestiton}
orderedLists={orderedLists}
/>
</div>
)}
</section>
);
};
const mapStateToProps = (state: AppState) => ({
active: state.active,
answerList: state.answerList,
surveyNotifications: state.surveyNotifications,
activeDependencies: state.activeDependencies,
});
const mapDispatchToProps = (dispatch: Dispatch) =>
bindActionCreators(
{
removeQuestionNotification,
setActiveQuestion,
setActiveModule,
setActiveSurveyScrollDirection,
},
dispatch
);
export default connect(mapStateToProps, mapDispatchToProps)(Survey);
Component B (wrong state)
const Question: React.FC<IProps> = (props: IProps) => {
const [showSubmitButton, setShowSubmitButton] = React.useState(false);
const [inState, setInState] = React.useState(true);
const classes = useStyles();
const { question, module, goToQuestion, active } = props;
const notifications: Array<IQuestionNotification> =
props.surveyNotifications[question.uuid] || [];
const answerArr = props.answerList[question.uuid];
const dependency = props.activeDependencies.questions[question.uuid];
useEffect(() => {
/**
* Function that moves to next or previous question based on the activeSurveyScrollDirection
*/
const move =
active.activeSurveyScrollDirection === "forwards"
? () => goToQuestion("next", false)
: () => goToQuestion("prev", false); // backwards
if (!dependency) {
if (!question.isVisible) move();
} else {
const { type } = dependency;
if (type === DependencyTypeEnum.SUBTRACT) {
console.log('DEPENDENCY MOVE')
move();
}
}
}, [dependency, question, active.activeQuestionUUID]);
console.log('Log from component B', active.activeQuestionUUID)
const goToNextQuestionWithTransition = (
where: string,
shouldPerformValidation?: boolean
) => {
// props.goToQuestion(where, shouldPerformValidation);
setInState(false);
setTimeout(() => {
props.goToQuestion(where, shouldPerformValidation);
}, 200);
};
/**
* Questions that only accept one answer will auto submit
* Questions that have more than one answer will display
* complete button after one answer is passed.
*/
const doAutoComplete = () => {
if (answerArr?.length) {
if (question.maxSelect === 1) {
goToNextQuestionWithTransition("next");
}
if (question.maxSelect > 1) {
setShowSubmitButton(true);
}
}
};
useDidUpdateEffect(() => {
doAutoComplete();
}, [answerArr]);
return (
<Grid container justify="center">
<Grid item xs={11} md={8} lg={5}>
<div className={clsx(classes.question, !inState && classes.questionOut)}>
<QuestionBody
question={question}
notifications={notifications}
module={module}
answerArr={answerArr}
/>
</div>
{showSubmitButton &&
active.activeQuestionUUID === question.uuid ? (
<Button
variant="contained"
color="secondary"
onClick={() => goToNextQuestionWithTransition("next")}
>
Ok!
</Button>
) : null}
</Grid>
</Grid>
);
};
const mapStateToProps = (state: AppState) => ({
surveyNotifications: state.surveyNotifications,
active: state.active,
answerList: state.answerList,
activeDependencies: state.activeDependencies,
});
const mapDispatchToProps = (dispatch: Dispatch) =>
bindActionCreators(
{
setActiveQuestion,
},
dispatch
);
export default connect(mapStateToProps, mapDispatchToProps)(Question);
Can you post a copy of the mapStateToProps of both component B and component A? If you are using reselect (or similar libraries), can you also post the selectors definitions?
Where are you putting the setTimeout() call?
If you are sure that there are no side effects within the mapStateToProps then it seems that you are mutating the activeQuestion property somewhere before or after the component B re-renders, assigning the old value. (Maybe you have to search for some assignement in conditions).
Also note that you can not always trust the console log, as it's value can be evaluated at later time the you call it.

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