redux calculated data re-rendering how to fix - reactjs

I have a list of cards that take the state tree.
I have one selector that gets the list of jobs, then two selectors that use that selection to map and combine an object to pass into the card.
function ProductionJobs(props) {
const jobData = useSelector(getDataForProductionJobs);
const dataData = useSelector(getDataForProduction(jobData.map(x=>x.jobsessionkey)));
const matData = useSelector(getMatsForProduction(jobData.map(x=>x.jobsessionkey)));
console.count("renders");
const combined = jobData.map(x=> {
const foundData = dataData.find(y=>y.attachedJobKey===x.jobsessionkey);
const foundMaterial = matData.filter(z=>z.attachedJobkey===x.jobsessionkey);
const obj = {...x}
if(foundData) obj.foundData = foundData;
if(foundMaterial) obj.material = foundMaterial;
return obj;
});
const productionCards = combined.map(x=><ProductionJobCard key={x.jobsessionkey} props={x} />)
return <div className="ProductionJobs">{productionCards}</div>
}
The problem is - this re-renders unnecessarily. Is there a better way of combining this data on the reducer's side, instead of the component?

You can create a container for ProductionJobCard and select combined items in that one using shallowEqual as second argument when filtering matData items.
const {
Provider,
useDispatch,
useSelector,
shallowEqual,
} = ReactRedux;
const { createStore, applyMiddleware, compose } = Redux;
const { createSelector } = Reselect;
const initialState = {
productionJobs: [
{ jobSessionKey: 1 },
{ jobSessionKey: 2 },
{ jobSessionKey: 3 },
{ jobSessionKey: 4 },
],
data: [{ id: 1, attachedJobKey: 1 }],
mat: [
{ id: 1, attachedJobKey: 1 },
{ id: 2, attachedJobKey: 1 },
{ id: 3, attachedJobKey: 2 },
],
};
//action types
const TOGGLE_MAT_ITEM = 'TOGGLE_MAT_ITEM';
const TOGGLE_DATA_ITEM = 'TOGGLE_DATA_ITEM';
const TOGGLE_JOB = 'TOGGLE_JOB';
//action creators
const toggleMatItem = () => ({ type: TOGGLE_MAT_ITEM });
const toggleDataItem = () => ({ type: TOGGLE_DATA_ITEM });
const toggleJob = () => ({ type: TOGGLE_JOB });
const reducer = (state, { type }) => {
if (type === TOGGLE_MAT_ITEM) {
//toggles matItem with id of 3 between job 1 or 2
return {
...state,
mat: state.mat.map((matItem) =>
matItem.id === 3
? {
...matItem,
attachedJobKey:
matItem.attachedJobKey === 2 ? 1 : 2,
}
: matItem
),
};
}
if (type === TOGGLE_DATA_ITEM) {
//toggles data between job 1 or 3
const attachedJobKey =
state.data[0].attachedJobKey === 1 ? 3 : 1;
return {
...state,
data: [{ id: 1, attachedJobKey }],
};
}
if (type === TOGGLE_JOB) {
//adds or removes 4th job
const productionJobs =
state.productionJobs.length === 3
? state.productionJobs.concat({ jobSessionKey: 4 })
: state.productionJobs.slice(0, 3);
return { ...state, productionJobs };
}
return state;
};
//selectors
const selectDataForProductionJobs = (state) =>
state.productionJobs;
const selectData = (state) => state.data;
const selectMat = (state) => state.mat;
const selectDataByAttachedJobKey = (attachedJobKey) =>
createSelector([selectData], (data) =>
data.find((d) => d.attachedJobKey === attachedJobKey)
);
const selectMatByAttachedJobKey = (attachedJobKey) =>
createSelector([selectMat], (mat) =>
mat.filter((m) => m.attachedJobKey === attachedJobKey)
);
//creating store with redux dev tools
const composeEnhancers =
window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(
reducer,
initialState,
composeEnhancers(
applyMiddleware(() => (next) => (action) =>
next(action)
)
)
);
const ProductionJobCard = (props) => (
<li><pre>{JSON.stringify(props, undefined, 2)}</pre></li>
);
const ProductionJobCardContainer = React.memo(
function ProductionJobCardContainer({ jobSessionKey }) {
//only one item, no need to shallow compare
const dataItem = useSelector(
selectDataByAttachedJobKey(jobSessionKey)
);
//shallow compare because filter always returns a new array
// only re render if items in the array change
const matItems = useSelector(
selectMatByAttachedJobKey(jobSessionKey),
shallowEqual
);
console.log('rendering:', jobSessionKey);
return (
<ProductionJobCard
dataItem={dataItem}
matItems={matItems}
jobSessionKey={jobSessionKey}
/>
);
}
);
const ProductionJobs = () => {
const jobData = useSelector(selectDataForProductionJobs);
const dispatch = useDispatch();
return (
<div>
<button onClick={() => dispatch(toggleMatItem())}>
toggle mat
</button>
<button onClick={() => dispatch(toggleDataItem())}>
toggle data
</button>
<button onClick={() => dispatch(toggleJob())}>
toggle job
</button>
<ul>
{jobData.map(({ jobSessionKey }) => (
<ProductionJobCardContainer
key={jobSessionKey}
jobSessionKey={jobSessionKey}
/>
))}
</ul>
</div>
);
};
ReactDOM.render(
<Provider store={store}>
<ProductionJobs />
</Provider>,
document.getElementById('root')
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.4/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.4/umd/react-dom.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/redux/4.0.5/redux.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-redux/7.2.0/react-redux.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/reselect/4.0.0/reselect.min.js"></script>
<div id="root"></div>
You should not combine the data on the reducer because you will essentially copy the data (combined data is essentially a copy of the data you already have). The combined data is a derived value and such values should not be stored in state but calculated in selectors, re calculate when needed by using memoization (not done here) but if you're interested you can see here how I use reselect for memoizing calculations.
At the moment the filter and find are run on each item but since the outcome is the same the component is not re rendered.

Related

What is the proper way to avoid rerenders when selecting an array in React Redux?

Is there a way to select a derived array from an array in a Redux store without spurious renders?
My Redux store contains an array of objects.
state = {items: [{id: 1, keys...}, {id: 2, keys...}, {id: 3, keys...}, ...]}
I wrote a selector to return an array of ids.
const selectIds = (state: MyStateType) => {
const {items} = state;
let result = [];
for (let i = 0; i < items.length; i++) {
result.push(items[I].id);
}
return result;
};
I then call this selector using react-redux's useSelector hook, inside a component to render out a list of components.
const MyComponent = () => {
const ids = useSelector(selectIds);
return (
<>
{ids.map((id) => (
<IdComponent id={id} key={id} />
))}
</>
);
};
I am finding that MyComponent is being rendered every call to dispatch which breaks down performance at a higher number of array elements.
I have passed in an equality function to useSelector like so:
import {shallowEqual, useSelector } from "react-redux";
const ids = useSelector(selectIds, (a, b) => {
if (shallowEqual(a, b)) {
return true;
}
if (a.length !== b.length) {
return false;
}
for (let i = 0; i < a.length; i++) {
if (a[i].id !== b[i].id) {
return false;
}
}
return true;
});
But dispatch is called enough times that checking equality becomes expensive with a large amount of array elements.
I have tried using the reselect library as well.
const selectItems = (state: MyStateType) => {
return state.items;
};
const selectIds = createSelector(
selectItems,
(items) => {
let result = [];
for (let i = 0; i < items.length; i++) {
result.push(items[i].id);
}
return result;
}
);
However, every time I modify the properties of one array element in state.items via dispatch, this changes the dependency of selectItems which causes selectIds to recalculate.
What I want is for selectIds to only recompute when the ids of state.items are modified. Is this possible?
I think the best you can do here is to combine reselect with the use of shallowEqual:
import { shallowEqual } from "react-redux";
const selectItems = (state: MyStateType) => state.items;
const selectIds = createSelector(
selectItems,
(items) => items.map(item => item.id)
);
const MyComponent = () => {
const ids = useSelector(selectIds, shallowEqual);
return (
<>
{ids.map((id) => (
<IdComponent id={id} key={id} />
))}
</>
);
};
Notes
I'm using Array.map to extract ids: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map
shallowEqual can be passed directly to useSelector
With the code above:
The array of ids will be re-created only if state.items change.
The ids variable will have a new reference only if the ids changed.
If this solution is not enough (can't afford the shallowEqual) you can take a look at https://github.com/dai-shi/react-tracked it uses a more precise system to track which part of the state is used (using Proxies: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy).
Another way of doing this is to memoize the ids array in the selector:
const { createSelector, defaultMemoize } = Reselect;
const selectItems = (state) => {
return state.items;
};
const selectIds = (() => {
//memoize the array
const memArray = defaultMemoize((...ids) => ids);
return createSelector(selectItems, (items) =>
memArray(...items.map(({ id }) => id))
);
})(); //IIFE
//test the code:
const state = {
items: [{ id: 1 }, { id: 2 }],
};
const result1 = selectIds(state);
const newState = {
...state,
items: state.items.map((item) => ({
...item,
newValue: 88,
})),
};
const result2 = selectIds(newState);
console.log('are they the same:', result1 === result2);
<script src="https://cdnjs.cloudflare.com/ajax/libs/reselect/4.0.0/reselect.min.js"></script>

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.

Passing data between components in React/Redux

I'm new to React and am trying to build an app which shuffles football players into two teams and am having difficulty with passing data from one component to another.
I have redux and react-redux installed.
In my reducer.js, I take a list of players and shuffle them, adding the shuffled list to state:
const shufflePlayers = (state) => {
return {
...state,
shuffledList: [
...state.playersList.sort(() => Math.random() - 0.5)
]
}
}
Then in 'src/components/DisplayTeams.index.js', I map the 'shuffledList' array to props:
import { connect } from "react-redux";
import DisplayTeams from "./DisplayTeams";
const mapStateToProps = (state) => {
return {
shuffledList: state.shuffledList,
};
};
export default connect(mapStateToProps)(DisplayTeams);
and finally, in 'src/components/DisplayTeams.js', I attempt to render the 'shuffledList' array in a list:
import React from 'react';
import '../../App.css';
const DisplayTeams = ({ shuffledList }) => (
<div>
<ul>
{shuffledList.map((player, index) => (
<li key={index}>{player.name}</li>
))}
</ul>
</div>
);
export default DisplayTeams;
but am getting TypeError: Cannot read property 'map' of undefined, indicating that the 'shuffledList' array is empty or not set at all.
Any help will be much appreciated!!
Two things:
You should add add an initial state, you can set it directly in the reducer file
const initialState = {
// other reducer parts here
shuffledList: []
}
The reducer should check the action type, otherwise it would run at any action. Something like this:
const shufflePlayers = (state = initialState, action) => {
switch (action.type) {
case actionTypes.SHUFFLE_LIST: {
// use a new array, avoid mutating the previous state
const sortedList = [...state.playersList].sort(() => Math.random() - 0.5)
return {
...state,
shuffledList: sortedList
}
}
}
You should not copy data in state, the list and shuffledList are the same data but shuffledList is a calculated result of list.
You can use a selector to calculate shuffled list from list instead to prevent it from re calculating on renders you can use reselect (should use that anyway) and memoize shuffled result as long as list doesn't change.
const { Provider, useSelector } = ReactRedux;
const { createStore, applyMiddleware, compose } = Redux;
const { createSelector } = Reselect;
const initialState = {
list: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
};
const reducer = (state = initialState) => state;
//selectors
const selectList = (state) => state.list;
//if state.list changes then it will shuffle again
const selectShuffledList = createSelector(
[selectList],
(list) => [...list].sort(() => Math.random() - 0.5)
);
const selectTeams = createSelector(
[selectShuffledList, (_, size) => size],
(shuffledList, teamSize) => {
const teams = [];
shuffledList.forEach((item, index) => {
if (index % teamSize === 0) {
teams.push([]);
}
teams[teams.length - 1].push(item);
});
return teams;
}
);
const selectTeamsCurry = (teamSize) => (state) =>
selectTeams(state, teamSize);
//creating store with redux dev tools
const composeEnhancers =
window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(
reducer,
initialState,
composeEnhancers(
applyMiddleware(() => (n) => (a) => n(a))
)
);
const App = () => {
//you can re render app with setCount
const [count, setCount] = React.useState(0);
//setting count has no effect on teams because
// state.list didn't change and selectShuffledList
// will use memoized shuffled result
const teams = useSelector(selectTeamsCurry(3));
return (
<div>
<button onClick={() => setCount((w) => w + 1)}>
re render {count}
</button>
<pre>{JSON.stringify(teams, undefined, 2)}</pre>
</div>
);
};
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.4/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.4/umd/react-dom.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/redux/4.0.5/redux.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-redux/7.2.0/react-redux.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/reselect/4.0.0/reselect.min.js"></script>
<div id="root"></div>
The above code looks fine. You can check the shuffledList in the initial state and also in the Redux store while dispatching actions.

Increase count and decrease count when action is dispatch

I am currently developing a front end application using react-redux. But I am very new to this language.
So basically I have the following UI
What I am trying to achieve is whenever users increase or decrease the option, it will store to the store procedure, and finally make an API call to backend and calculate pricing.
Before API call, my idea is I will let users to increase/decrease the option and finally when the user is done, i will take that array of object and submit to the api endpoint.
Unfortunately, It seems like the following scenario is failed.
I increase option 1, it will save to the state as an array of object
first time with quantity and optionId [OK]
After that, I will increase the option 2, since it is the new option,
I will push the object to the existing array. [OK]
When I try to increase option 1 again, it has to check whether option
1 is already inside the array, if there is option 1, it will just
increase that option quantity. but my code does not behave that way. [FAILED]
below is my Component
import React, {Component} from 'react';
import {handleIncreaseOption} from '../actions/option';
import {Button, Card, Col, Row, Statistic} from "antd";
import {MinusOutlined, PlusOutlined} from '#ant-design/icons';
import {connect} from 'react-redux';
const {Meta} = Card;
class FlavourCard extends Component {
state = {
quantity: 0,
optionId: this.props.optionId
}
increase = () => {
let count = this.state.quantity + 1;
this.setState({
quantity: count,
optionId: this.props.optionId
}, function(){
console.log('this state before going in', this.state);
this.props.dispatch(handleIncreaseOption(this.state));
});
}
decline = () => {
let count = this.state.count - 1;
if (count < 0) {
count = 0;
}
this.setState({count: count});
console.log(this.state);
}
render() {
const {flavourImg, itemTitle} = this.props;
return (
<Card
hoverable
cover={<img alt="example" className="flavour-img" src={flavourImg}/>}
>
<Meta
title={itemTitle}
style={{textAlign: 'center'}}
description={
<Row justify="start" gutter={12}>
<Col span={10} style={{textAlign: 'right', paddingTop: '6px'}}>
<Button onClick={this.decline} size="small">
<MinusOutlined/>
</Button>
</Col>
<Col span={4}>
<Statistic value={this.state.quantity} style={{fontSize: '10px'}}/>
</Col>
<Col span={10} style={{textAlign: 'left', paddingTop: '6px'}}>
<Button onClick={this.increase} size="small">
<PlusOutlined/>
</Button>
</Col>
</Row>
}
/>
</Card>
)
}
}
function mapStateToProps(state) {
return{
loadingBar: state.loadingBar
}
}
export default connect(mapStateToProps) (FlavourCard)
This is my action class
export const RETRIEVE_OPTIONS = 'RETRIEVE_OPTIONS';
export const INCREASE_OPTIONS = 'INCREASE_OPTIONS';
export function receiveOptions( option ) {
return {
type: RETRIEVE_OPTIONS,
option
}
}
export function handleIncreaseOption ( option ) {
return {
type: INCREASE_OPTIONS,
option
}
}
This is my reducer
import {RETRIEVE_OPTIONS, INCREASE_OPTIONS} from "../actions/option";
export default function option ( state = null , action )
{
switch (action.type) {
case RETRIEVE_OPTIONS:
return {
...state,
...action
}
case INCREASE_OPTIONS:
if ( !state.hasOwnProperty('addOption') ) {
return {
...state,
addOption: [
{
quantity: action.option.quantity,
optionId: action.option.optionId
}
]
}
}
state.addOption.map((opt) => {
if(opt.optionId === action.option.optionId) {
opt.quantity = action.option.quantity;
}else {
let originalAddOption = state.addOption;
originalAddOption.push({
quantity: action.option.quantity,
optionId: action.option.optionId
})
}
return {
...state,
...action
}
})
default:
return state
}
}
I believe that my "INCREASE_OPTIONS" reducer is something wrong, because, the correct logic should be when there is a new optionId, it will add in as a new object, and if the optionId is existing one, it will just increase the entity. For my current code, whenever I make a second option to increase, it will just add in a new object with new quantity value. I have attached the console result below
How can I achieve when there is existing option, just increase/decrease the quantity and if option is newly added, make a new object and push to the array? Thanks in advance
There are a couple problems in the reducer.
The first issue is that you are trying to update the state object directly. This will not work, you have to set state to a new object.
The second issue is how you are using the map function. It looks like you are using it to update a value if it exists, or add a new entry if it does not. You might have to separate that out and first check if it exists, if so do an update, if not add a new element. Then for each opt in the array, you return an object containing the entire state and action, which I don't think is your intention.
Try out something like this in the reducer:
case INCREASE_OPTIONS: {
if ( !state.hasOwnProperty('addOption') ) {
return {
...state,
addOption: [
{
quantity: action.option.quantity,
optionId: action.option.optionId
}
]
}
}
let updated = false;
// For every element, check if we find the id to modify
// Map returns an array. Does not modify in place.
let addOptCopy = state.addOption.map((opt) => {
if(opt.optionId === action.option.optionId) {
opt.quantity = action.option.quantity;
updated = true;
}
return opt;
});
// If nothing was updated, push new element
if(!updated){
addOptCopy.push({
quantity: action.option.quantity,
optionId: action.option.optionId
})
}
// return the new state
return {
...state,
addOption: [...addOptCopy]
}
}
As one of the comments on your post suggested, it may be an over complication to be keeping two states, using the components state plus redux state and keeping them in sync. You can do the increase and decrease within the reducer, and get the state from props in the components by linking it in mapStateToProps.
Lastly, there seems to be a typo in the decrease function, you are setting count in state instead of quantity.
Here is a functional example, you only need to pass id to the increaseOption action creator:
const { Provider, useDispatch, useSelector } = ReactRedux;
const { createStore, applyMiddleware, compose } = Redux;
const { createSelector } = Reselect;
const initialState = {
data: [
{
id: 1,
},
{
id: 2,
},
],
};
//action types
const INCREASE_OPTIONS = 'INCREASE_OPTIONS';
//action creators
const increaseOption = (id) => ({
type: INCREASE_OPTIONS,
payload: id,
});
const reducer = (state, { type, payload }) => {
if (type === INCREASE_OPTIONS) {
const addOption = state.addOption || [];
const exist = addOption.some(
({ optionId }) => optionId === payload
);
return {
...state,
addOption: exist
? addOption.map((option) =>
option.optionId === payload
? { ...option, quantity: option.quantity + 1 }
: option
)
: addOption.concat({
optionId: payload,
quantity: 1,
}),
};
}
return state;
};
//selectors
const selectData = (state) => state.data;
const selectOption = (state) => state.addOption || [];
const createSelectOption = (id) =>
createSelector([selectOption], (options) => {
const option = options.find(
({ optionId }) => optionId === id
);
return option ? option.quantity : 0;
});
const createSelectItem = (itemId) =>
createSelector([selectData], (data) =>
data.find(({ id }) => id === itemId)
);
const createSelectCardProp = (id) =>
createSelector(
[createSelectOption(id), createSelectItem(id)],
(option, item) => ({ option, item })
);
//creating store with redux dev tools
const composeEnhancers =
window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(
reducer,
initialState,
composeEnhancers(
applyMiddleware(() => (n) => (a) => n(a))
)
);
const FlavourCard = React.memo(function FlavourCard({
id,
}) {
const dispatch = useDispatch();
const selectProps = React.useMemo(
() => createSelectCardProp(id),
[id]
);
const props = useSelector(selectProps);
return (
<button onClick={() => dispatch(increaseOption(id))}>
id: {props.item.id} count:{props.option}
</button>
);
});
const App = () => {
const data = useSelector(selectData);
return (
<ul>
{data.map(({ id }) => (
<FlavourCard key={id} id={id} />
))}
</ul>
);
};
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.4/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.4/umd/react-dom.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/redux/4.0.5/redux.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-redux/7.2.0/react-redux.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/reselect/4.0.0/reselect.min.js"></script>
<div id="root"></div>

React hook useEffect failed to read new useState value that is updated with firebase's firestore realtime data

I have an array of data object to be rendered. and this array of data is populated by Firestore onSnapshot function which i have declared in the React hook: useEffect. The idea is that the dom should get updated when new data is added to firestore, and should be modified when data is modified from the firestore db.
adding new data works fine, but the problem occurs when the data is modified.
here is my code below:
import React, {useState, useEffect} from 'react'
...
const DocList = ({firebase}) => {
const [docList, setDocList] = useState([]);
useEffect(() => {
const unSubListener = firebase.wxDocs()
.orderBy("TimeStamp", "asc")
.onSnapshot({
includeMetadataChanges: true
}, docsSnap => {
docsSnap.docChanges()
.forEach(docSnap => {
let source = docSnap.doc.metadata.fromCache ? 'local cache' : 'server';
if (docSnap.type === 'added') {
setDocList(docList => [{
source: source,
id: docSnap.doc.id,
...docSnap.doc.data()
}, ...docList]);
console.log('document added: ', docSnap.doc.data());
} // this works fine
if (docSnap.type === 'modified') {
console.log('try docList from Lists: ', docList); //this is where the problem is, this returns empty array, i don't know why
console.log('document modified: ', docSnap.doc.data()); //modified data returned
}
})
})
return () => {
unSubListener();
}
}, []);
apparently, i know the way i declared the useEffect with empty deps array is to make it run once, if i should include docList in the deps array the whole effect starts to run infinitely.
please, any way around it?
As commented, you could have used setDocList(current=>current.map(item=>..., here is working example with fake firebase:
const firebase = (() => {
const createId = ((id) => () => ++id)(0);
let data = [];
let listeners = [];
const dispatch = (event) =>
listeners.forEach((listener) => listener(event));
return {
listen: (fn) => {
listeners.push(fn);
return () => {
listeners = listeners.filter((l) => l !== fn);
};
},
add: (item) => {
const newItem = { ...item, id: createId() };
data = [...data, newItem];
dispatch({ type: 'add', doc: newItem });
},
edit: (id) => {
data = data.map((d) =>
d.id === id ? { ...d, count: d.count + 1 } : d
);
dispatch({
type: 'edit',
doc: data.find((d) => d.id === id),
});
},
};
})();
const Counter = React.memo(function Counter({ up, item }) {
return (
<button onClick={() => up(item.id)}>
{item.count}
</button>
);
});
function App() {
const [docList, setDocList] = React.useState([]);
React.useEffect(
() =>
firebase.listen(({ type, doc }) => {
if (type === 'add') {
setDocList((current) => [...current, doc]);
}
if (type === 'edit') {
setDocList((current) =>
current.map((item) =>
item.id === doc.id ? doc : item
)
);
}
}),
[]
);
const up = React.useCallback(
(id) => firebase.edit(id),
[]
);
return (
<div>
<button onClick={() => firebase.add({ count: 0 })}>
add
</button>
<div>
{docList.map((doc) => (
<Counter key={doc.id} up={up} item={doc} />
))}
</div>
</div>
);
}
ReactDOM.render(<App />, document.getElementById('root'));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.4/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.4/umd/react-dom.production.min.js"></script>
<div id="root"></div>
You can do setDocList(docList.map... but that makes docList a dependency of the effect: useEffect(function,[docList]) and the effect will run every time docList changes so you need to remove the listener and idd it every time.
In your code you did not add the dependency so docList was a stale closure. But the easiest way would be to do what I suggested and use callback for setDocList: setDocList(current=>current.map... so docList is not a dependency of the effect.
The comment:
I don't think setDocList, even with the the prevState function, is guaranteed to be up to date by the time you get into that if statement
Is simply not true, when you pass a callback to state setter the current state is passed to that callback.
Based on #BrettEast suggestion;
I know this isn't what you want to hear, but I would probably suggest using useReducer reactjs.org/docs/hooks-reference.html#usereducer, rather than useState for tracking an array of objects. It can make updating easier to track. As for your bug, I don't think setDocList, even with the the prevState function, is guaranteed to be up to date by the time you get into that if statement.
I use useReducer instead of useState and here is the working code:
import React, {useReducer, useEffect} from 'react'
import { withAuthorization } from '../../Session'
import DocDetailsCard from './Doc';
const initialState = [];
/**
* reducer declaration for useReducer
* #param {[*]} state the current use reducer state
* #param {{payload:*,type:'add'|'modify'|'remove'}} action defines the function to be performed and the data needed to execute such function in order to modify the state variable
*/
const reducer = (state, action) => {
switch (action.type) {
case 'add':
return [action.payload, ...state]
case 'modify':
const modIdx = state.findIndex((doc, idx) => {
if (doc.id === action.payload.id) {
console.log(`modified data found in idx: ${idx}, id: ${doc.id}`);
return true;
}
return false;
})
let newModState = state;
newModState.splice(modIdx,1,action.payload);
return [...newModState]
case 'remove':
const rmIdx = state.findIndex((doc, idx) => {
if (doc.id === action.payload.id) {
console.log(`data removed from idx: ${idx}, id: ${doc.id}, fullData: `,doc);
return true;
}
return false;
})
let newRmState = state;
newRmState.splice(rmIdx,1);
return [...newRmState]
default:
return [...state]
}
}
const DocList = ({firebase}) => {
const [state, dispatch] = useReducer(reducer, initialState)
useEffect(() => {
const unSubListener = firebase.wxDocs()
.orderBy("TimeStamp", "asc")
.onSnapshot({
includeMetadataChanges: true
}, docsSnap => {
docsSnap.docChanges()
.forEach(docSnap => {
let source = docSnap.doc.metadata.fromCache ? 'local cache' : 'server';
if (docSnap.type === 'added') {
dispatch({type:'add', payload:{
source: source,
id: docSnap.doc.id,
...docSnap.doc.data()
}})
}
if (docSnap.type === 'modified') {
dispatch({type:'modify',payload:{
source: source,
id: docSnap.doc.id,
...docSnap.doc.data()
}})
}
if (docSnap.type === 'removed'){
dispatch({type:'remove',payload:{
source: source,
id: docSnap.doc.id,
...docSnap.doc.data()
}})
}
})
})
return () => {
unSubListener();
}
}, [firebase]);
return (
<div >
{
state.map(eachDoc => (
<DocDetailsCard key={eachDoc.id} details={eachDoc} />
))
}
</div>
)
}
const condition = authUser => !!authUser ;
export default React.memo(withAuthorization(condition)(DocList));
also according to #HMR, using the setState callback function:
here is the updated code which also worked if you're to use useState().
import React, { useState, useEffect} from 'react'
import { withAuthorization } from '../../Session'
import DocDetailsCard from './Doc';
const DocList = ({firebase}) => {
const [docList, setDocList ] = useState([]);
const classes = useStyles();
useEffect(() => {
const unSubListener = firebase.wxDocs()
.orderBy("TimeStamp", "asc")
.onSnapshot({
includeMetadataChanges: true
}, docsSnap => {
docsSnap.docChanges()
.forEach(docSnap => {
let source = docSnap.doc.metadata.fromCache ? 'local cache' : 'server';
if (docSnap.type === 'added') {
setDocList(current => [{
source: source,
id: docSnap.doc.id,
...docSnap.doc.data()
}, ...current]);
console.log('document added: ', docSnap.doc.data());
}
if (docSnap.type === 'modified') {
setDocList(current => current.map(item => item.id === docSnap.doc.id ? {
source: source,
id: docSnap.doc.id,
...docSnap.doc.data()} : item )
)
}
if (docSnap.type === 'removed'){
setDocList(current => {
const rmIdx = current.findIndex((doc, idx) => {
if (doc.id === docSnap.doc.id) {
return true;
}
return false;
})
let newRmState = current;
newRmState.splice(rmIdx, 1);
return [...newRmState]
})
}
})
})
return () => {
unSubListener();
}
}, [firebase]);
return (
<div >
{
docList.map(eachDoc => (
<DocDetailsCard key={eachDoc.id} details={eachDoc} />
))
}
</div>
)
}
const condition = authUser => !!authUser ;
export default React.memo(withAuthorization(condition)(DocList));
Thanks hope this help whoever is experiencing similar problem.

Resources