Component rerenders with wrong redux state? - reactjs

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.

Related

Why are my state values breaking on dispatch?

I am working on a react app where I have a userSettings screen for the user to update their settings on clicking a save button. I have two sliding switches that are saved and a dispatch function is ran to post the data.
Each switch has their own toggle function, and all the functions run at the same time.
My problem is that when I pass the userSettings object to the child component and run both functions, it runs with the wrong values which results in the data not saving properly.
Here is my code:
Parent component that has the toggle functions, handles the state, and set the userSettings object:
class SideMenu extends React.PureComponent {
constructor(props) {
super(props);
const userToggleSettings = {
cascadingPanels: this.props.userSettings.usesCascadingPanels,
includeAttachments: this.props.userSettings.alwaysIncludeAttachments,
analyticsOptIn: false,
};
this.state = {
userToggleSettings,
};
}
toggleIncludeAttachments = () => {
this.setState((prevState) => ({
userToggleSettings: {
...prevState.userToggleSettings,
includeAttachments: !prevState.userToggleSettings.includeAttachments,
},
}));
};
toggleCascadingPanels = () => {
this.setState((prevState) => ({
userToggleSettings: {
...prevState.userToggleSettings,
cascadingPanels: !prevState.userToggleSettings.cascadingPanels,
},
}));
};
includeAttachmentsClickHandler = () => {
this.toggleIncludeAttachments();
};
cascadingPanelsClickHandler = () => {
this.toggleCascadingPanels();
};
render() {
const darkThemeClass = this.props.isDarkTheme ? "dark-theme" : "";
const v2Class = this.state.machineCardV2Enabled ? "v2" : "";
const phAdjustmentStyle = this.getPersistentHeaderAdjustmentStyle();
const closeButton =
(this.state.machineListV2Enabled &&
this.props.view === sideMenuViews.USER_SETTINGS) ||
(!this.props.wrapper && this.props.view === sideMenuViews.SETTINGS);
return (
<div className="sideMenuFooter">
<SideMenuFooterContainer
userToggleSettings={this.state.userToggleSettings} //HERE IS USER_SETTINGS PASSED
/>
</div>
);
}
}
The child component that dispatches the data
SideMenuFooterContainer:
export function mapStateToProps(state) {
return {
translations: state.translations,
userSettings: state.appCustomizations.userSettings,
};
}
export function mapDispatchToProps(dispatch) {
return {
toggleCascadingPanels: (hasCascadingPanels) =>
dispatch(userSettingsDux.toggleCascadingPanels(hasCascadingPanels)),
toggleIncludeAttachments: (hasIncludeAttachments) =>
dispatch(userSettingsDux.toggleIncludeAttachments(hasIncludeAttachments)),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(SideMenuFooter);
SideMenuFooterView (where it calls the dispatch):
const saveUserSettings = (props) => {
console.log("userSettings ==>\n");
console.log(props.userToggleSettings);
props.toggleIncludeAttachments(props.userToggleSettings.includeAttachments);
props.toggleCascadingPanels(props.userToggleSettings.cascadingPanels);
};
const cancelButtonClickHandler = (props) => {
if (props.viewTitle === props.translations.USER_SETTINGS) {
return () => props.closeSideMenu();
}
return () => props.viewBackButtonCallback();
};
const doneSaveButtonsClickHandler = (props) => {
return () => {
saveUserSettings(props);
props.closeSideMenu();
};
};
const SideMenuFooter = (props) => {
return (
<div className="side-menu-footer">
<div className="side-menu-footer-container">
<button
className="btn btn-secondary"
onClick={cancelButtonClickHandler(props)}
>
{props.translations.CANCEL}
</button>
<button
className="btn btn-primary"
onClick={doneSaveButtonsClickHandler(props)}
>
{props.translations.SAVE}
</button>
</div>
</div>
);
};
export default SideMenuFooter;
Dispatch functions:
export function toggleIncludeAttachments(hasIncludeAttachments) {
return async (dispatch, getState) => {
const { translations, appCustomizations } = getState();
const updatedUserSettings = {
...appCustomizations.userSettings,
alwaysIncludeAttachments: hasIncludeAttachments,
};
try {
await saveAppCustomizationByName(
CUSTOMIZATIONS.USER_SETTINGS,
updatedUserSettings
);
dispatch(setSettings(updatedUserSettings));
} catch (err) {
dispatch(
bannerDux.alertBanne({
description: "FAILED TO UPDATE USER DATA",
})
);
}
};
}
export function toggleCascadingPanels(hasCascadingPanels) {
return async (dispatch, getState) => {
const { translations, appCustomizations } = getState();
const updatedUserSettings = {
...appCustomizations.userSettings,
usesCascadingPanels: hasCascadingPanels,
};
try {
await saveAppCustomizationByName(
CUSTOMIZATIONS.USER_SETTINGS,
updatedUserSettings
);
dispatch(setSettings(updatedUserSettings));
} catch (err) {
dispatch(
bannerDux.alertBanner({
description: "FAILED TO UPDATE USER DATA",
})
);
}
};
}
Here is a demo:
When I set them both to false and console log the values, it looks like it is getting the correct values, but in the network call, it is getting different values on different calls
console.log output:
First network call to save data header values:
Second network call to save data header values:
NOTE: The dispatch functions work correctly, they where there before all the edits. I am changing the way it saves the data automatically to the save button using the same functions defined before.
Did I miss a step while approaching this, or did I mishandle the state somehow?

Can't update Zustand state using setState function

In Comp1 when i hover mouse on it i want the state to change to true (I'm passing true param to it). Also i want to ensure that by doing so this will not cause the Comp2 component to re-render.
My understanding was that if i do like so useStoreOnHover.setState({ onComp1: true }) it should work but it does not :(
I have also tried with const onComp1Set = useStoreOnHover((s) => s.onComp1Set) but still same :(
The only way i was able to get it working is by const { onComp1Set } = useStoreOnHover() but I'm trying to avoid these type of de-structuring because it also triggers re-renders to other components.
Live example: https://codesandbox.io/s/winter-grass-qxrv8
import create, { GetState, SetState } from "zustand";
type typeStoreOnHover = {
onComp1: boolean;
onComp1Set: (val: boolean) => void;
onComp2: boolean;
};
export const useStoreOnHover = create<typeStoreOnHover>(
(set: SetState<typeStoreOnHover>, get: GetState<typeStoreOnHover>) => {
return {
onComp1: false,
onComp1Set: (val) => set({ onComp1: val }),
onComp2: false
};
}
);
const Comp1 = () => {
const onComp1 = useStoreOnHover.getState().onComp1;
// const onComp1Set = useStoreOnHover((s) => s.onComp1Set);
console.log("Comp 1", onComp1);
return (
<div
onMouseEnter={() => {
// onComp1Set(true);
useStoreOnHover.setState({ onComp1: true });
}}
>
Comp 1 {onComp1 ? "True" : "False"}
</div>
);
};
const Comp2 = () => {
const onComp2 = useStoreOnHover((s) => s.onComp2);
console.log("Comp 2", onComp2);
return <div>Comp 2 </div>;
};
export default function App() {
return (
<>
<Comp1 />
<Comp2 />
</>
);
}
According to zustand documentation, this should be the actual approach,
const onComp1 = useStoreOnHover((s) => s.onComp1);
I have tested it on your CodeSandBox link and it worked.
I am not familiar with the zustand library but this might help you.

Two components get called when page refreshes and the state is altered React

Basically, I have one component, let's call it component1 and a second component, which has been created by duplicating the first one called component2. I had to duplicate it, because some objects inside it had to be altered before sending them to the further components.
On one page I have an onClick event which triggers component1 which opens a modal and on another page, component2 is trigger the same as for the first one.
The problem occurs here, if I'm on the second page where the modal from component2 is opened and I refresh the page, both components are called, of course component1 is the first one called and the state is altered by this component which makes me not having the desired information in the second component.
As far as I understood, because of the fact that in both components, mapStateToProps is altering my state, both components are called. Not really sure though that I understood right.
Here is my component1 summary:
class LivePlayerModal extends React.Component {
constructor(props) {
super(props);
this.highlightsUpdated = null;
}
componentDidMount() {
const queryParam = UrlHelper.getParamFromLocation(IS_QUALIFICATION, window.location);
if (queryParam === null) {
ScoringLoader.subscribe(endpointNames.LIVE_SCANNER);
ScoringLoader.subscribe(endpointNames.PLAYERS);
ScoringLoader.subscribe(endpointNames.LEADERBOARD);
ScoringLoader.subscribe(endpointNames.COURSE);
ScoringLoader.subscribe(endpointNames.STATISTICS);
}
//TODO: make fixed fetch on timeout
this.fetchHighlights();
}
componentDidUpdate(prevProps) {
if (prevProps.playerId !== this.props.playerId) {
this.highlightsUpdated = null;
}
this.fetchHighlights();
}
componentWillUnmount() {
ScoringLoader.unsubscribe(endpointNames.LIVE_SCANNER);
ScoringLoader.unsubscribe(endpointNames.PLAYERS);
ScoringLoader.unsubscribe(endpointNames.LEADERBOARD);
ScoringLoader.unsubscribe(endpointNames.COURSE);
ScoringLoader.unsubscribe(endpointNames.STATISTICS);
}
render() {
const {
isOpen, scoringPlayer, isQualification, ...rest
} = this.props;
const highlightGroups = getHighlights(this.getCloudHighlights());
if (isQualification) {
return null;
}
return (
<ReactModal isOpen={isOpen} onCloseCb={this.hide}>
<div className="live-player">
{
scoringPlayer === undefined &&
<BlockPlaceholder minHeight={400}>
<BlockSpinner />
</BlockPlaceholder>
}
{
scoringPlayer === null &&
<LivePreMessage
model={{
title: '',
body: 'Player data coming soon'
}}
bemList={[bemClasses.LIGHT]}
/>
}
{
scoringPlayer &&
<LivePlayerLayout
{...rest}
scoringPlayer={scoringPlayer}
highlightGroups={highlightGroups}
/>
}
</div>
</ReactModal>
);
}
}
const mapStateToProps = (state, ownProps) => {
const isQualification = state.scoring.isQualification;
const { playerId } = ownProps;
const sitecorePlayers = state.scoring[endpointNames.PLAYERS];
const scoringLeaderboard = state.scoring[endpointNames.LEADERBOARD];
const getScoringPlayer = () => {
};
return ({
isQualification,
liveScanner: state.scoring[endpointNames.LIVE_SCANNER],
scoringLeaderboard,
scoringPlayer: getScoringPlayer(),
scoringStats: state.scoring[endpointNames.STATISTICS],
scoringCourse: state.scoring[endpointNames.COURSE],
sitecorePlayers: state.scoring[endpointNames.PLAYERS],
cloudMatrix: state.cloudMatrix
});
};
const mapDispatchToProps = (dispatch) => ({
fetchPlayerHighlights: (feedUrl) => dispatch(fetchFeed(feedUrl))
});
const LivePlayerCardContainer = connect(
mapStateToProps,
mapDispatchToProps
)(LivePlayerModal);
export default LivePlayerCardContainer;
Here is my component2 summary :
class QualificationLivePlayerModal extends React.Component {
constructor(props) {
super(props);
this.highlightsUpdated = null;
}
shouldComponentUpdate(nextProps) {
return nextProps.isQualification;
}
componentDidMount() {
ScoringLoader.subscribe(endpointNames.SUMMARY_FINAL);
ScoringLoader.subscribe(endpointNames.SUMMARY_REGIONAL);
ScoringLoader.subscribe(endpointNames.LIVE_SCANNER);
ScoringLoader.subscribe(endpointNames.PLAYERS);
ScoringLoader.subscribe(endpointNames.COURSE);
ScoringLoader.unsubscribe(endpointNames.LEADERBOARD);
ScoringLoader.unsubscribe(endpointNames.STATISTICS);
//TODO: make fixed fetch on timeout
this.fetchHighlights();
}
componentDidUpdate(prevProps) {
if (prevProps.playerId !== this.props.playerId) {
this.highlightsUpdated = null;
}
this.fetchHighlights();
}
componentWillUnmount() {
ScoringLoader.unsubscribe(endpointNames.SUMMARY_FINAL);
ScoringLoader.unsubscribe(endpointNames.SUMMARY_REGIONAL);
ScoringLoader.unsubscribe(endpointNames.COURSE);
ScoringLoader.unsubscribe(endpointNames.LEADERBOARD);
ScoringLoader.unsubscribe(endpointNames.STATISTICS);
}
render() {
const {
scoringPlayer, summaryFinal, ...rest
} = this.props;
const highlightGroups = getHighlights(this.getCloudHighlights());
const queryParam = UrlHelper.getParamFromLocation(IS_QUALIFICATION, window.location);
const open = (queryParam === 'true');
if (scoringPlayer !== undefined && scoringPlayer !== null) scoringPlayer.id = scoringPlayer.entryId;
return (
<ReactModal isOpen={open} onCloseCb={this.hide}>
<div className="qual-live-player">
{
scoringPlayer === undefined &&
<BlockPlaceholder minHeight={400}>
<BlockSpinner />
</BlockPlaceholder>
}
{
scoringPlayer === null &&
<LivePreMessage
model={{
title: '',
body: 'Player data coming soon'
}}
bemList={[bemClasses.LIGHT]}
/>
}
{
scoringPlayer &&
<LivePlayerLayout
{...rest}
scoringPlayer={scoringPlayer}
highlightGroups={highlightGroups}
/>
}
</div>
</ReactModal>
);
}
}
const mapStateToProps = (state, ownProps) => {
const isQualification = state.scoring.isQualification;
const { playerId, location } = ownProps;
const locationIdFromQueryParam = UrlHelper.getParamFromLocation(LOCATION_ID, window.location);
const locationId = location !== null ? location.locationId : locationIdFromQueryParam;
const sitecorePlayers = state.scoring[endpointNames.PLAYERS];
const summaryRegional = state.scoring[endpointNames.SUMMARY_REGIONAL];
const summaryFinal = state.scoring[endpointNames.SUMMARY_FINAL];
const scoringLeaderboard = getLeaderboardBasedOnLocation(locationId, summaryFinal, summaryRegional);
const currentRound = getCurrentRound(locationId, summaryFinal, summaryRegional);
const getScoringPlayer = () => {
};
return ({
isQualification,
liveScanner: state.scoring[endpointNames.LIVE_SCANNER],
scoringLeaderboard,
scoringPlayer: getScoringPlayer(),
scoringCourse: getScoringCourseFromQualificationFeed(),
sitecorePlayers: state.scoring[endpointNames.PLAYERS],
cloudMatrix: state.cloudMatrix,
});
};
const mapDispatchToProps = (dispatch) => ({
fetchPlayerHighlights: (feedUrl) => dispatch(fetchFeed(feedUrl))
});
const QualificationLivePlayerCardContainer = connect(
mapStateToProps,
mapDispatchToProps
)(QualificationLivePlayerModal);
export default QualificationLivePlayerCardContainer;
Basically, the problem i ve got here, is that in state.scoring I do not have the information for the endpoints present in the return statement of the render method before the page finishes the refresh process, which later on makes my app to break.
Hope I've been clear enough.
Is there a solution for waiting the endpoints to get called or even not loading the first component at all?

accessing the 'this' keyword in a react functional component

I'm trying to understand how make a component that can remove itself from a array of components with functional components. Here is the sample code of what I'm trying to do:
const App = () => {
<ObjState>
<ObjectCreator />
<ObjectList />
</ObjState>
}
const ObjContext = createContext();
const ObjReducer = (state, { type, payload }) => {
switch(type) {
case Types.ADD_OBJ:
return {
...state,
objects: [...state.objects, payload]
};
case Types.REMOVE_OBJ:
return {
...state,
objects: state.objects.filter(obj => obj !== payload)
};
default:
return state;
}
}
const ObjState = ({ children }) => {
const initialState = {
objects: []
}
const [state, dispatch] = useReducer(ObjRecuder, initialState);
const addObj = (obj) => {
dispatch({
type: Types.ADD_OBJ,
payload: obj
});
}
const removeObj = (obj) => {
dispatch({
type: Types.REMOVE_OBJ,
payload: obj
});
}
return (
<ObjContext.Provider value={{
objects: state.objects,
addObj,
removeObj
}}>
{children}
</ObjContext.Provider>
);
}
const ObjCreator = () => {
const { addObject } = useContext(ObjContext);
const createObj =() => {
const obj = (<ObjectTypeA key={uuid()} />);
addObject(obj);
}
return (<button onClick={createObj}>create an object!</button>)
}
const ObjectList = () => {
const { objects } = useContext(ObjContext)
return (
<fragment>
{objects}
</fragment>
)
}
const ObjectTypeA = ({ key }) => {
const { removeObj } = useContext(ObjContext);
const removeSelf = () => {
removeObj(this);
}
return (
<button onClick={removeSelf}>remove me!</button>
)
}
The problem is you can't reference this in the final Object component.
I have the unique key but I'm not sure how to pass it through correctly. I attempted to build a reducer action that took the key from the Object and removed it that way but key came back as undefined even though it is deconstructed out of the props and I'm using an arrow function to preserve it.
I feel like I'm tackling this problem in the wrong way.
Issue
I think you veer off-course when trying to store what looks to be React components in your context state, you should be storing objects instead. The objects should have unique GUIDs. This allows the reducer to identify which object element to remove from state. The ObjectList should then render derived React components from the stored state.
I attempted to build a reducer action that took the key from the
Object and removed it that way but key came back as undefined even
though it is deconstructed out of the props and I'm using an arrow
function to preserve it.
This is because React keys (and refs) are not actually props. Keys can't be accessed in children components. You can can pass the same value via just about any other named prop though. Note below in solution I pass a React key and an id prop.
Solution
ObjectCreator: Creates objects, not React components
const ObjectCreator = () => {
const { addObj } = useContext(ObjContext);
const createObj = () => {
const obj = {
id: uuid()
};
addObj(obj);
};
return <button onClick={createObj}>create an object!</button>;
};
SpecificObject: passes its id to the removeObj callback.
const MyObject = ({ id }) => {
const { removeObj } = useContext(ObjContext);
const removeSelf = () => {
removeObj(id);
};
return (
<div>
<button onClick={removeSelf}>remove {id}</button>
</div>
);
};
ObjectList: renders the context objects mapped to JSX.
const ObjectList = () => {
const { objects } = useContext(ObjContext);
return (
<>
{objects.map((el) => (
<MyObject key={el.id} id={el.id} />
))}
</>
);
};
Check the passed id payload in the remove object reducer
const ObjReducer = (state, { type, payload }) => {
switch (type) {
case Types.ADD_OBJ:
return {
...state,
objects: [...state.objects, payload]
};
case Types.REMOVE_OBJ:
return {
...state,
objects: state.objects.filter((obj) => obj.id !== payload)
};
default:
return state;
}
};
Demo

Infinite scroll updates data twice

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)

Resources