I'm building a block based editor from scratch. I have the model as
initialState = {
id,
name,
blocks: [{
id,
content: [{text: '', attributes: []}]
}]
}
In My React Editor is a component that will have this state in the useReducer.
const Editor: React.FC<Props> = ({ document }: Props) => {
const reducer = (state: any, action: any) =>
produce(state, (draft: any) => {
switch (action.operation) {
case ReducerActions.create: {
const index = draft.blocks.findIndex(
(block: any) => block.id === action.block.prevBlock,
);
draft.blocks.splice(index + 1, 0, action.block);
break;
}
case ReducerActions.delete:
draft.blocks = draft.blocks.filter(
(block: any) => block.id !== action.blockId,
);
break;
case ReducerActions.updateContent: {
draft.blocks = draft.blocks.map((block: any) => {
if (block.id === action.blockId) {
return { ...block, content: action.content };
}
return block;
});
break;
}
}
});
const [state, dispatch]: any = useReducer(reducer, document, initialState);
const handleOnInput = (block: any, blockAction: any) => {
switch (blockAction.action) {
case BlockOperations.backSpace: {
const index = state.blocks.findIndex(
(elem: any) => elem.id === block.id,
);
if (index === 0 || index === -1) {
return;
}
const prevBlock = { ...state.blocks[index - 1] };
prevBlock.content = [...prevBlock.content, ...block.content];
dispatch({
operation: ReducerActions.updateContent,
blockId: prevBlock.id,
content: prevBlock.content,
});
dispatch({
operation: ReducerActions.delete,
blockId: block.id,
});
break;
}
}
};
return(
<div>
<Toolbar/>
<div id="editor">
{state.blocks.map((block: any) => (
<div key={block.id} id={block.id}>
<Block
key={block.id}
block={block}
onInput={handleOnInput}
onSelect={handleSelect}
/>
</div>
))}
</div>
</div>
)
}
So I was memoizing Block component and wrapped all the props for block in useCallback to avoid reinitializing.
But the problem started with OnInput backspace case where i need to access the other blocks content and frame the new content. So i need the latest state that forced me to remove the useCallback and that makes all the changes to state rerender the whole block list.
Is there any way that we could split the state in better way and avoid rerendering of all blocks?
One approach that i have in my mind is let the blocks manage their state and only on blur of editable element will update the state in the parent block. But that would affect us in future where we might need collaboration or we could make the api calls directly in the block content to update the particular block. Since the backend schema is structured in such a way that
documentModel = {
id,
name,
blockIds,
}
blockModel = {
id,
type,
content,
}
Thanks for the help.
Related
Stackoverflow
problem
I have separate components that house Tiptap Editor tables. At first I had a save button for each Child Component which worked fine, but was not user friendly. I want to have a unified save button that will iterate through each child Table component and funnel all their editor.getJSON() data into an array of sections for the single doc object . Then finish it off by saving the whole object to PouchDB
What did I try?
link to the repo → wchorski/Next-Planner: a CRM for planning events built on NextJS (github.com)
Try #1
I tried to use the useRef hook and the useImperativeHandle to call and return the editor.getJSON(). But working with an Array Ref went over my head. I'll post some code of what I was going for
// Parent.jsx
const childrenRef = useRef([]);
childrenRef.current = []
const handleRef = (el) => {
if(el && !childrenRef.current.includes(el)){
childrenRef.current.push(el)
}
}
useEffect(() =>{
childrenRef.current[0].childFunction1() // I know this doesn't work, because this is where I gave up
})
// Child.jsx
useImperativeHandle(ref, () => ({
childFunction1() {
console.log('child function 1 called');
},
childFunction2() {
console.log('child function 2 called');
},
}))
Try #2
I set a state counter and passed it down as a prop to the Child Component . Then I update the counter to trigger a child function
// Parent.jsx
export const Planner = ({id, doc, rev, getById, handleSave, db, alive, error}) => {
const [saveCount, setSaveCount] = useState(0)
const handleUpdate = () =>{
setSaveCount(prev => prev + 1)
}
const isSections = () => {
if(sectionsState[0]) handleSave(sectionsState)
if(sectionsState[0] === undefined) console.log('sec 0 is undefined', sectionsState)
}
function updateSections(newSec) {
setsectionsState(prev => {
const newState = sectionsState.map(obj => {
if(!obj) return
if (obj.header === newSec.header) {
return {...obj, ...newSec}
}
// 👇️ otherwise return object as is
return obj;
});
console.log('newState', newState);
return newState;
});
}
useEffect(() => {
setsectionsState(doc.sections)
}, [doc])
return (<>
<button
title='save'
className='save'
onPointerUp={handleUpdate}>
Save to State <FiSave />
</button>
<button
style={{right: "0", width: 'auto'}}
title='save'
className='save'
onClick={isSections}>
Save to DB <FiSave />
</button>
{doc.sections.map((sec, i) => {
if(!sec) return
return (
<TiptapTable
key={i}
id={id}
rev={doc.rev}
getById={getById}
updateSections={updateSections}
saveCount={saveCount}
section={sec}
db={db}
alive={alive}
error={error}
/>
)
})}
</>)
// Child.jsx
export const TiptapTable = ((props, ref) => {
const {id, section, updateSections, saveCount} = props
const [currTimeStart, setTimeStart] = useState()
const [defTemplate, setdefTemplate] = useState('<p>loading<p>')
const [isLoaded, setIsLoaded] = useState(false)
const [notesState, setnotesState] = useState('')
const editor = useEditor({
extensions: [
History,
Document,
Paragraph,
Text,
Gapcursor,
Table.configure({
resizable: true,
}),
TableRow.extend({
content: '(tableCell | tableHeader)*',
}),
TableHeader,
TableCell,
],
// i wish it was this easy
content: (section.data) ? section.data : defTemplate,
}, [])
const pickTemplate = async (name) => {
try{
const res = await fetch(`/templates/${name}.json`,{
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
const data = await res.json()
setIsLoaded(true)
setdefTemplate(data)
console.log('defTemplate, ', defTemplate);
// return data
} catch (err){
console.warn('template error: ', err);
}
}
function saveData(){
console.log(' **** SAVE MEEEE ', section.header);
try{
const newSection = {
header: section.header,
timeStart: currTimeStart,
notes: notesState,
data: editor.getJSON(),
}
updateSections(newSection)
} catch (err){
console.warn('table update error: ', id, err);
}
}
useEffect(() => {
// 👇️ don't run on initial render
if (saveCount !== 0) saveData()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [saveCount])
useEffect(() => {
setTimeStart(section.timeStart)
setnotesState(section.notes)
if(!section.data) pickTemplate(section.header).catch(console.warn)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, section, isLoaded])
useEffect(() => {
if (editor && !editor.isDestroyed) {
if(section.data) editor.chain().focus().setContent(section.data).run()
if(!section.data) editor.chain().focus().setContent(defTemplate).run()
setIsLoaded(true)
}
}, [section, defTemplate, editor]);
if (!editor) {
return null
}
return isLoaded ? (<>
<StyledTableEditor>
<div className="title">
<input type="time" label='Start Time' className='time'
onChange={(e) => setTimeStart(e.target.value)}
defaultValue={currTimeStart}
/>
<h2>{section.header}</h2>
</div>
<EditorContent editor={editor} className="tiptap-table" ></EditorContent>
// ... non relavent editor controls
<button
title='save'
className='save2'
onPointerUp={() => saveData()}>
Save <FiSave />
</button>
</div>
</nav>
</StyledTableEditor>
</>)
: null
})
TiptapTable.displayName = 'MyTiptapTable';
What I Expected
What I expected was the parent state to update in place, but instead it overwrites the previous tables. Also, once it writes to PouchDB it doesn't write a single piece of new data, just resolved back to the previous, yet with an updated _rev revision number.
In theory I think i'd prefer the useRef hook with useImperativeHandle to pass up the data from child to parent.
It looks like this question is similar but doesn't programmatically comb through the children
I realize I could have asked a more refined question, but instead of starting a new question I'll just answer my own question from what I've learned.
The problem being
I wasn't utilizing React's setState hook as I iterated and updated the main Doc Object
Thanks to this article for helping me through this problem.
// Parent.jsx
import React, {useState} from 'react'
import { Child } from '../components/Child'
export const Parent = () => {
const masterDoc = {
_id: "123",
date: "2023-12-1",
sections: [
{header: 'green', status: 'old'},
{header: 'cyan', status: 'old'},
{header: 'purple', status: 'old'},
]
}
const [saveCount, setSaveCount] = useState(0)
const [sectionsState, setsectionsState] = useState(masterDoc.sections)
function updateSections(inputObj) {
setsectionsState(prev => {
const newState = prev.map(obj => {
// 👇️ if id equals 2, update country property
if (obj.header === inputObj.header)
return {...obj, ...inputObj}
return obj;
});
return newState;
});
}
return (<>
<h1>Parent</h1>
{sectionsState.map((sec, i) => {
if(!sec) return
return (
<Child
key={i}
section={sec}
updateSections={updateSections}
saveCount={saveCount}
/>
)
})}
<button
onClick={() => setSaveCount(prev => prev + 1)}
>State dependant update {saveCount}</button>
</>)
}
// Child.jsx
import React, {useEffect, useState, forwardRef, useImperativeHandle} from 'react'
export const Child = forwardRef((props, ref) => {
const {section, updateSections, saveCount} = props
const [statusState, setStatusState] = useState(section.status)
function modData() {
const obj = {
header: section.header,
status: statusState
}
updateSections(obj)
}
useEffect(() => {
// 👇️ don't run on initial render
if (saveCount !== 0) modData()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [saveCount])
return (<>
<span style={{color: section.header}}>
header: {section.header}
</span>
<span>status: {section.status}</span>
<input
defaultValue={section.status}
onChange={(e) => setStatusState(e.target.value)}
/>
________________________________________
</>)
})
Child.displayName = 'MyChild';
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
I have part of a reducer as an example:
case TOGGLE_FAVORITE: {
const cropId = Number(action.cropId);
const crop = state.crops.find(cr => cr.id === cropId);
crop.isFavorite = !crop.isFavorite;
return {
...state
};
}
I am updating only the top level state. This is wrong according to the redux documentation, but it doesn't explain why exactly. 'isFavorite' is a toggleable field, and the change is reflected in a react component, so the change of the 'isFavorite' field is visibly happening.
I was under the impression, that updating only the top level state must be avoided in redux, because the 'state.crop' reference won't change in the above case, and React will not know it should rerender.
My question is, why exactly is the above code example wrong? What problems could it cause down the line? Why does it seem to be working?
You can find plenty pro claims for using immutable data by reading the Immutable Data Management reading list that you find in docs.
But I believe that foremost example will be when you subscribed to a partial state change:
const cropId = CROP_ID_TO_FIND;
// Won't render component!
const myCropObj = useReducer(state => state.crops.find(cr => cr.id === cropId));
Here is a full example to check my claim, notice that pressing "Mutable" button won't update state, where "Immutable" does:
// reducers
const list = (state = [], action) => {
switch (action.type) {
case "MUTATE_STATE": {
const [initial] = state;
initial.text = "mutate state";
return [...state];
}
case "IMMUTABLE_STATE": {
return [{ text: "immutable state" }];
}
default:
return state;
}
};
const store = createStore(combineReducers({ list }), {
list: [{ text: "initial" }]
});
function MutateState() {
const dispatch = useDispatch();
return (
<button onClick={() => dispatch({ type: "MUTATE_STATE" })}>Mutable</button>
);
}
function ImmutableState() {
const dispatch = useDispatch();
return (
<button onClick={() => dispatch({ type: "IMMUTABLE_STATE" })}>
Immutable
</button>
);
}
function SelectorState() {
// Subscribed to a partial state
const list = useSelector((state) => state.list[0]);
console.log("rendered on subscribe change");
return (
<>
<h1>Store</h1>
<pre>{JSON.stringify(list, null, 2)}</pre>
</>
);
}
ReactDOM.render(
<Provider store={store}>
<MutateState />
<ImmutableState />
<SelectorState />
</Provider>,
document.getElementById("root")
);
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.
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>