Creating an object with hooks as property - reactjs

Description:
I am using Radix-UI and I want to create a service for creating toasts:
https://www.radix-ui.com/docs/primitives/components/toast
Problem:
I am unable to change the statte of the open-hook inside the object
How it works:
Any random component from anywhere in the app can add toasts to the store
The Toastr.tsx watches the store toasts
whenever a new object/ToastrItem is added it renders the toast
After 5 seconds (default value) onOpenChangeis called to set open=false through a hook. This does not work
Toastr.tsx
const Toastr = () => {
const { toasts, setToasts } = useToastrStore();
const eventDateRef = React.useRef(new Date());
const timerRef = React.useRef(0);
React.useEffect(() => {
console.log(toasts);
return () => clearTimeout(timerRef.current);
}, []);
React.useEffect(() => {
console.log('TOASTS:');
console.log(toasts);
}, [toasts]);
return (
<ToastProvider swipeDirection='right'>
{toasts &&
toasts.length > 0 &&
isArray(toasts) &&
toasts.map((toast: ToastrItem, idx: number) => {
return (
<Toast
open={toast.open}
onOpenChange={(openStatus: boolean) => {
//This does not work
toast.setOpen(openStatus)
}}
key={idx}
>
<ToastTitle>
{toast.title}
<h1>{toast.open}</h1>
</ToastTitle>
<ToastDescription asChild>
<time dateTime={eventDateRef.current.toISOString()}>{'TEEEEEEEEEEEEEEEEEEEEEEEEEEXT'}</time>
</ToastDescription>
<ToastAction asChild altText='Goto schedule to undo'>
<Button variant='green' size='small'>
Close
</Button>
</ToastAction>
</Toast>
);
})}
<ToastViewport />
</ToastProvider>
);
};
The piece of code that does not work is:
onOpenChange={(openStatus: boolean) => {
//This does not work
toast.setOpen(openStatus)
}}
My theory is that it is because of the generator i am using:
getToast.tsx
export const getToast = () => {
const toast = new ToastObject() as ToastrItem;
const [open, setOpen] = useState(false);
toast.open = open;
toast.setOpen = setOpen;
return toast;
};
class ToastObject implements ToastrItem {
id: string;
title: string;
description: string;
variant: ToastrVariant;
open: boolean;
setOpen: (param: boolean) => void;
constructor() {
this.id = nanoid();
this.title = 'test';
this.description = 'description';
this.variant = ToastrVariant.GOOD;
this.open = true;
this.setOpen = () => false;
}
}
ToastrItem.tsx
export interface ToastrItem {
id: string;
title: string;
description: string;
variant: ToastrVariant;
open: boolean;
setOpen: (parameter: boolean) => any;
}

What are you using getToast()?
I think I see the problem without seeing the usage. React state is immutable and can only be changed by calling setState(). This means that if you modify a value within the current state, React doesn't know that the state has actually changed. You need to instead copy the original state, update what you need, and then call setState again.
Since you're using zustand, I'll show how you would accomplish this using that. I haven't actually tested this code, but it's the correct paradigm for what you want to do.
const useToastrStore = create((set) => ({
toasts: [],
// addToast will update the toasts state by appending the new toast
addToast: (toast) => set(state => ({toasts: [...state.toasts, toast]})),
// setOpen takes a toastIndex and an open status
setOpen: (toastIndex, openStatus) => {
set(state => ({
...state,
// map through all toasts, find the one that matches toastIndex, and change it's open status
toasts: state.toasts.map((toast, index) => index === toastIndex ? {
...toast,
open: openStatus
} : {...toast})
})
)
}
}))
const Toastr = () => {
const { toasts, addToast, setOpen } = useToastrStore();
return (
<ToastProvider swipeDirection='right'>
{toasts.map((toast: ToastrItem, idx: number) => (
<Toast
open={toast.open}
onOpenChange={(openStatus: boolean) => {
//This does not work
setOpen(idx, openStatus)
}}
key={idx}
>
</Toast>
)}
</ToastProvider>
);
};

Related

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.

How to change results as far as I move map in react-instantsearch-dom-maps

I am currently working with algolia and I have some strange issue with the map. I have this interface (left column is results, right column is map) like on this picture:
When I am trying to drag my map I get for a few seconds results, that I need, but for some reason they reset to previous results from searchboxes.My map code is like this:
const Marker = ({ hit }: { hit: any }) => {
const rHit = hit;
return (
<CustomMarker
key={rHit.objectID}
hit={rHit}
anchor={{ x: 0, y: (rHit.position - rHit.count) * 50 }} // if there are several markers at the exact same position
>
<ArtistsMarker hit={rHit} />
</CustomMarker>
);
};
const Map: FC<MapProps> = ({ fullscreen }): ReactElement => {
const [google, setGoogle] = useState<any>();
useEffect(() => {
if (window) {
setGoogle(window.google);
}
}, []);
const getKey = (hit: any): string => `${hit._geoloc.lat} ${hit._geoloc.lng}`;
const groupThoseWithIdenticalCoordinates = (array: any[]) =>
array.reduce((acc, value) => {
if (!acc[getKey(value)]) {
acc[getKey(value)] = [];
}
acc[getKey(value)].push(value);
value.position = acc[getKey(value)].length;
return acc;
}, []);
const addPositionForIdenticalCoordinates = (
array: any[],
grouped: any
): any[] =>
array.map((e: any) => ({ ...e, count: grouped[getKey(e)].length }));
return google ? (
<GeoSearch
google={google}
enableRefine={true}
enableRefineOnMapMove={true}
maxZoom={17}
gestureHandling={fullscreen ? 'cooperative' : 'greedy'}
>
{(value: any) => {
const grouped = groupThoseWithIdenticalCoordinates(value.hits);
const hits = addPositionForIdenticalCoordinates(value.hits, grouped);
const markers = hits.map((hit: any) => (
<Marker hit={hit} key={hit.uid} />
));
return (
<div>
<Control />
{markers}
</div>
);
}}
</GeoSearch>
) : (
<></>
);
};
export default Map;
Also I have noticed inside my console, that searchState object sometimes misses boundingBox object like this:
Maybe someone knows why can its happen or where should I search?
The issue was caused by filters. I filtered results by location, so thats why I was returned to previous location.

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.

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)

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.

Resources