Calls to setState not executing in proper order [duplicate] - reactjs

This question already has answers here:
The useState set method is not reflecting a change immediately
(15 answers)
Closed 12 months ago.
I am having issues with (I think) the timing of the sequential calls to setState variables... specifically in my 'handleStartOverClick()' call. This function makes two 'setState' calls, builds a list, then selects a random item from that list. However, my console.logs in that method are showing that nothing is actually getting cleared, and the new list isn't getting built. I am pretty sure this isn't the best 'react way' to handle the synchronous nature of these calls, but I just haven't been able to find a way to properly handle from the docs/otherwise.
So far I've tried wrapping in useCallback(), making the methods async to use .then, and even brute forcing a timeout in between calls just to see it work. But no luck.
Also, I've included the json that is being pulled in with the import of 'questionsJson' at the top, even though that is maybe an extraneous detail.
import React, { useEffect, useMemo, useState } from 'react';
import { Grid } from '#material-ui/core';
import questionsJson from 'assets/data/questions.json';
import { Question } from 'domains/core/models';
export default function Questions(): ReactElement {
const [leftOptionSelected, setLeftOptionSelected] = useState(false);
const [rightOptionSelected, setRightOptionSelected] = useState(false);
const [mainQuestionList, setMainQuestionList] = useState<Question[]>([]);
const [currentQuestion, setCurrentQuestion] = useState<Question | undefined>(undefined);
const [questionsLoaded, setQuestionsLoaded] = useState(false);
const removeQuestionFromMainQuestionList = useMemo(() => (questionToRemove: Question) => {
const updatedQuestionList = mainQuestionList.filter(question => question?.id !== questionToRemove?.id);
setMainQuestionList(updatedQuestionList);
},[setMainQuestionList, mainQuestionList]);
const getRandomQuestion = useMemo(() => () => {
for (let index = 0; index < mainQuestionList.length; index++) {
const numberOfQuestions = mainQuestionList.length;
const randomArrayPosition = Math.floor(Math.random() * numberOfQuestions);
const randomQuestion = mainQuestionList[randomArrayPosition];
setCurrentQuestion({
id: randomQuestion.id,
option1: randomQuestion.option1,
option2: randomQuestion.option2
});
removeQuestionFromMainQuestionList(randomQuestion);
console.log("new main question list", mainQuestionList)
}
},[mainQuestionList, removeQuestionFromMainQuestionList]);
const buildQuestionList = () => {
const questionList: Question[] = [];
const questionInfo = questionsJson.questionInfo;
for (let index = 0; index < questionInfo.length; index++) {
const currentQuestionSet = questionInfo[index];
let questions = currentQuestionSet.questions;
const questionLimit = currentQuestionSet.questionLimit;
for (let index = 0; index < questionLimit; index++) {
const numberOfQuestions = questions.length;
const randomArrayPosition = Math.floor(Math.random() * numberOfQuestions);
const questionToAdd = questions[randomArrayPosition];
questionList.push(questionToAdd);
questions = questions.filter(question => question?.id !== questionToAdd?.id);
}
}
setMainQuestionList(questionList);
console.log("initial question list", questionList);
setQuestionsLoaded(true);
}
useEffect(() => {
buildQuestionList();
}, []);
useEffect(() => {
if (questionsLoaded){
getRandomQuestion();
}
}, [questionsLoaded]);
const handleStartOverClick = () => {
console.log("in handle start over")
setCurrentQuestion(undefined);
console.log("current question", currentQuestion);
setMainQuestionList([]);
console.log("mainQuestionList", mainQuestionList);
buildQuestionList();
console.log("mainQuestionList", mainQuestionList);
setTimeout( () => {
getRandomQuestion();
console.log("current question after clearout", currentQuestion);
}, 4000);
}
const handleOptionClick = (sideClicked: string) => {
if (sideClicked == "left"){
setLeftOptionSelected(true);
setTimeout( () => {
setLeftOptionSelected(false);
getRandomQuestion();
}, 2000);
} else {
setRightOptionSelected(true);
setTimeout( () => {
setRightOptionSelected(false)
getRandomQuestion();
}, 2000);
}
}
return (
<Grid container direction="row" spacing={6}>
<Grid style={{ backgroundColor: "yellow"}} item xs={6}>
<div onClick={() => handleOptionClick("left")} style={{ pointerEvents: rightOptionSelected ? 'none' : 'auto'}}>
<div>{currentQuestion?.option1}</div>
</div>
</Grid>
<Grid style={{ backgroundColor: "green"}} item xs={6}>
<div onClick={() => handleOptionClick("right")} style={{ pointerEvents: leftOptionSelected ? 'none' : 'auto'}}>
<span></span>
<div style={{ backgroundColor: "green"}}>{currentQuestion?.option2}</div>
</div>
</Grid>
<Grid item xs={2}>
<button onClick={() => handleStartOverClick()}>
START OVER
</button>
</Grid>
</Grid>
);
}
{
"questionInfo":[
{
"category":"fun",
"questionLimit":2,
"questions":[
{
"id":1,
"option1":"Apple pie",
"option2":"Cherry pie"
},
{
"id":2,
"option1":"Jack",
"option2":"Jill"
}
]
},
{
"category":"lifestyle",
"questionLimit":2,
"questions":[
{
"id":3,
"option1":"Workout",
"option2":"Watch tv"
},
{
"id":4,
"option1":"Day",
"option2":"Night"
},
{
"id":5,
"option1":"Watch",
"option2":"Read"
}
]
},
{
"category":"interest",
"questionLimit":2,
"questions":[
{
"id":6,
"option1":"Cars",
"option2":"Baseball"
},
{
"id":7,
"option1":"Money",
"option2":"Fame"
}
]
}
]
}

Never set state and use the state value in the same function. Set state is async and will always console.log stale data in the same function.
simple solution:
const [state, setState] = useState({id: 0})
const someFunction = () => {
const newState = {id: 1}
setState(newState)
//console.log(state) NO! Stale data
console.log(newState) //New data. Use this instead of state inside this function
}
useEffect(() => {
console.log(state) //New, updated value
}, [state]) //Only console log if new state is changed.

From your implementation,
I can suggest you this way
Instead of using this
useEffect(() => {
if (questionsLoaded){
getRandomQuestion();
}
}, [questionsLoaded]);
You should change it to this
useEffect(() => {
if (mainQuestionList && mainQuestionList.length > 0){
getRandomQuestion();
}
}, [mainQuestionList]);
it means whenever you modify the question list, we will get the random question again.
After this change, you can get rid of questionsLoaded.
And in handleStartOverClick, you can remove the call of getRandomQuestion with setTimeout (buildQuestionList will help you to handle that case with useEffect after getting question list).

Related

useState array as dependency causing infinite loop

I have been struggling with this for hours, i'm new to React and would appreciate any assistance.
I'm working on something where users can pick regions into an array.
My main problem is that i want the array that users choose to have unique values only.
I have tried using a javascript SET but that can't be mapped through. The array will be mapped through then displayed to the user.
And i have tried setting "if" statements, that check for duplicate values, inside useEffect but the dependency on a useState array creates an infinite loop.
I have read about using useRef on an array to avoid useEffect infinite loops but i find that its normally for static rather than changing arrays.
Below is the important part:
const [regions, setRegions] = useState([]);
const [region, setRegion] = useState("");
useEffect(() => {
if (region) {
if (regions.includes.region) {
return;
} else if (!regions.includes.region) {
setRegions((prevValue) => {
return [...prevValue, region];
});
}
}
// setRegions((previousState) => new Set([...previousState, region]));
}, [region, regions]);
The rest of the code for context:
import { useEffect, useState } from "react";
import PlacesAutocomplete, {
geocodeByAddress,
getLatLng,
} from "react-places-autocomplete";
export default function Test() {
const [address, setAddress] = useState("");
const [coordinate, setCoordinates] = useState({
lat: null,
lng: null,
});
const [regions, setRegions] = useState([]);
const [region, setRegion] = useState("");
useEffect(() => {
if (region) {
if (regions.includes.region) {
return;
} else if (!regions.includes.region) {
setRegions((prevValue) => {
return [...prevValue, region];
});
}
}
// setRegions((previousState) => new Set([...previousState, region]));
}, [region, regions]);
const handleSelect = async (value) => {
const result = await geocodeByAddress(value);
const full_region = result[0].formatted_address;
const part_region = full_region.substring(0, full_region.indexOf(","));
let province = "";
if (result[0].address_components[2].short_name.length <= 3) {
province = result[0].address_components[2].short_name;
} else {
province = result[0].address_components[3].short_name;
}
setAddress(value);
setCoordinates(coordinate);
setRegion(part_region.concat("-", province));
};
const onDelete = (e) => {
const value = e.target.getAttribute("value");
console.log("onDelete: ", value);
setRegions(regions.filter((item) => item !== value));
};
// setRegions(Array.from(new Set(regions)));
return (
<>
<PlacesAutocomplete
value={address}
onChange={setAddress}
onSelect={handleSelect}
searchOptions={{
componentRestrictions: { country: ["za"] },
types: ["(regions)"],
}}
>
{({ getInputProps, suggestions, getSuggestionItemProps, loading }) => (
<div>
<input
{...getInputProps({
placeholder: "Add regions...",
className: "location-search-input",
})}
/>
<div className="autocomplete-dropdown-container">
{loading && <div>Loading...</div>}
{suggestions.map((suggestion) => {
const className = suggestion.active
? "suggestion-item--active"
: "suggestion-item";
// inline style for demonstration purpose
const style = suggestion.active
? { backgroundColor: "orange", cursor: "pointer" }
: { backgroundColor: "silver", cursor: "pointer" };
return (
<div
key={suggestion.description}
{...getSuggestionItemProps(suggestion, {
className,
style,
})}
>
<span>{suggestion.description}</span>
</div>
);
})}
</div>
</div>
)}
</PlacesAutocomplete>
<p>Regions</p>
<ul>
{regions.map((region) => (
<li
// key={region}
title="remove"
className="cursor-pointer"
onClick={onDelete}
value={region}
>
{region}
</li>
))}
</ul>
</>
);
}
You are using .includes() incorrectly by trying to obtain region as a property: regions.includes.region
This results in:
the second condition else if (!regions.includes.region) always succeeding,
which then results in the state change setRegions() being made,
which then triggers the [regions] in the dependency,
which then loops the useEffect() again, and again.. ..infinitely.
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/includes
Instead, it should be passed as a parameter to the method: if(regions.includes(region)) and if(!regions.includes(region))
useEffect(() => {
if (region) {
if (regions.includes(region)) {
return;
}
if (!regions.includes(region)) {
setRegions((prevValue) => {
return [...prevValue, region];
});
}
}
}, [region, regions]);
You could probably also simplify it by only modifying the state if the condition doesn't succeed:
useEffect(() => {
if (region) {
if (!regions.includes(region)) {
setRegions((prevValue) => {
return [...prevValue, region];
});
}
// else do nothing
}
}, [region, regions]);

React | Collect State Values of Children Array and update Per Object and Save to PouchDB

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';

React Typewriter effect doesn't reset

I have created a typewriting effect with React and it works perfectly fine. However, when I change the language with i18n both texts don't have the same length and it keeps writing until both texts have the same length and then it changes the language and starts the effect again.
How can I reset the input when the language has changed? How can I reset the input when the component has been destroyed?
I have recorded a video
I have the same issue when I change from one page to another, as both pages have different texts and they don't have the same length.
Here code of my component
export const ConsoleText = ({text, complete = false}) => {
const [currentText, setCurrentText] = useState("");
const translatedText = i18n.t(text);
const index = useRef(0);
useEffect(() => {
if (!complete && currentText.length !== translatedText.length) {
const timeOut = setTimeout(() => {
setCurrentText((value) => value + translatedText.charAt(index.current));
index.current++;
}, 20);
return () => {
clearTimeout(timeOut);
}
} else {
setCurrentText(translatedText);
}
}, [translatedText, currentText, complete]);
return (
<p className="console-text">
{currentText}
</p>
);
};
You are telling react to do setCurrentText(translatedText) only when it is complete or when the compared text lengths are equal, so yes it continues to write until this moment.
To reset your text when text changes, try creating another useEffect that will reset your states :
useEffect(() => {
index.current = 0;
setCurrentText('');
}, [text]);
Now, I actually did this exact same feature few days ago, here is my component if it can help you :
import React from 'react';
import DOMPurify from 'dompurify';
import './text-writer.scss';
interface ITextWriterState {
writtenText: string,
index: number;
}
const TextWriter = ({ text, speed }: { text: string, speed: number }) => {
const initialState = { writtenText: '', index: 0 };
const sanitizer = DOMPurify.sanitize;
const [state, setState] = React.useState<ITextWriterState>(initialState);
React.useEffect(() => {
if (state.index < text.length - 1) {
const animKey = setInterval(() => {
setState(state => {
if (state.index > text.length - 1) {
clearInterval(animKey);
return { ...state };
}
return {
writtenText: state.writtenText + text[state.index],
index: state.index + 1
};
});
}, speed);
return () => clearInterval(animKey);
}
}, []);
// Reset the state when the text is changed (Language change)
React.useEffect(() => {
if (text.length > 0) {
setState(initialState);
}
}, [text])
return <div className="text-writer-component"><span className="text" dangerouslySetInnerHTML={{ __html: sanitizer(state.writtenText) }} /></div>
}
export default TextWriter;
The translation is made outside of the component so you can pass any kind of text to the component.

How to create a dynamic array of React hooks for an array of components

const AnimatedText = Animated.createAnimatedComponent(Text);
function Component({ texts }) {
const [visitIndex, setVisitIndex] = React.useState(0);
// can't create an array of shared value for each text
// since useSharedValue is a hook, and that throws a warning
const textScalesShared = texts.map((_) => useSharedValue(1));
// can't create an array of animated style for each text
// since useAnimatedStyle is a hook, and that throws a warning
const animatedTextStyle = textScalesShared.map((shared) =>
useAnimatedStyle(() => ({
transform: [{ scale: shared.value }],
}))
);
useEffect(() => {
// code to reduce text scale one after another
// it will loop over the array of textScaleShared values
// passed to each component and update it
if (visitIndex === texts.length) {
return;
}
textScalesShared[visitIndex].value = withDelay(
1000,
withTiming(0.5, {
duration: 1000,
})
);
const timerId = setTimeout(() => {
setVisitIndex((idx) => idx + 1);
}, 1000);
return () => {
clearTimeout(timerId);
};
}, [visitIndex]);
return texts.map((text, index) => {
if (index <= visitIndex) {
return (
<AnimatedRevealingText
key={index}
fontSize={fontSize}
revealDuration={revealDuration}
style={animatedStylesShared[index]}
{...props}
>
{text}
</AnimatedRevealingText>
);
} else {
return null;
}
});
}
I want to apply animated styles to an array of components, but since useSharedValue and useAnimatedStyle are both hooks, I am unable to loop over the prop and create a shared value and the corresponding style for each of the component.
How can I achieve the same?
EDIT: updated to add the full code.
You can create a component to handle the useSharedValue and useAnimatedStyle hooks for every item using the visitIndex value:
AnimatedTextItem.js
const AnimatedText = Animated.createAnimatedComponent(Text);
const AnimatedTextItem = ({text, visited}) => {
const textScaleShared = useSharedValue(1);
const style = useAnimatedStyle(() => ({
transform: [{ textScaleShared.value }],
}));
useEffect(()=> {
if(visited) {
textScaleShared.value = withDelay(
1000,
withTiming(0.5, {
duration: 1000,
});
);
}
}, [visited]);
return (<AnimatedText style={style}>{text}</AnimatedText>)
}
Component.js
function Component({texts}) {
const [visitIndex, setVisitIndex] = React.useState(0);
useEffect(() => {
// code to reduce text scale one after another
// it will loop over the array of textScaleShared values
// passed to each component and update it
if (visitIndex === texts.length) {
return;
}
const timerId = setTimeout(() => {
setVisitIndex((idx) => idx + 1);
}, revealDuration);
return () => {
clearTimeout(timerId);
};
}, []);
return texts.map((text, index) => (<AnimatedTextItem text={text} visited={visitIndex === index}/>))
}
You can compose a component to handle it for you, but you need to pass the index of the text you're mapping through.
Like this
const AnimatedText = ({styleIndex}) => {
const textScaleShared = useSharedValue(styleIndex + 1);
const animatedTextStyle = useAnimatedStyle(() => ({
transform: [{ scale: textScaleShared.value }],
}));
const Animated = Animated.createAnimatedComponent(Text);
return <Animated style={animatedTextStyle}>{text}</Animated>;
};
function Component({ texts }) {
useEffect(() => {
// code to reduce text scale one after another
}, []);
return texts.map((text, index) => (
<AnimatedText key={index} styleIndex={index}>
{text}
</AnimatedText>
));
}
Interesting problem :) Let me see if i can come up a solution.
You already notice hook can't be in a dynamic array since the length of array is unknown.
Multiple components
You can have as many as components as you want, each one can have a hook, ex.
const Text = ({ text }) => {
// useSharedValue(1)
// useAnimatedStyle
}
const Components = ({ texts }) => {
return texts.map(text => <Text text={text} />)
}
Single hook
You can also see if you can find a className that can apply to all components at the same time. It's css i assume.

Better use of useEffect hooks as to not need use Callbacks

I have written the following React component and it's messy with the the dependencies and need for
useCallback
My question is: What about hooks am I misunderstanding to where I need to use this useCallback all the time?
const TagPicker = ({ value, defaultValue, tags, updateTags }) => {
const thisPicker = useRef(null);
const thisInput = useRef(null);
// Managing Tags
const [ selectedTags, setSelectedTags ] = useState(value || defaultValue);
const getIds = useCallback(() => {
let ids = [];
for(let x in selectedTags) {
ids.push(selectedTags[x].id);
}
return ids;
}, [ selectedTags ]);
const addTag = useCallback(tag => {
let ids = getIds();
if(!ids.includes(tag.id)) setSelectedTags([ ...selectedTags, tag ]);
}, [ selectedTags, getIds ]);
const removeTag = tag => {
let ids = getIds();
if(ids.includes(tag.id)) setSelectedTags([ ...selectedTags].filter(t => (t.id !== tag.id)));
}
// Typed text / Filtered List
const [ text, setText ] = useState('');
const [ filteredList, setFilteredList ] = useState([]);
useEffect(() => {
let ids = getIds();
if(text.length) {
let filteredTags = tags.filter(tag => (!ids.includes(tag.id)));
setOpen(true);
let list = [];
for(let x in filteredTags) {
if(filteredTags[x].title.toLowerCase().includes(text.toLowerCase())) list.push(filteredTags[x]);
}
setFilteredList(list);
} else {
setOpen(false);
setFilteredList([])
}
}, [ selectedTags, tags, text, getIds ]);
useEffect(() => {
updateTags(selectedTags);
}, [ updateTags, selectedTags ]);
// Toggling dropdown menu
const [ open, setOpen ] = useState(false);
useEffect(() => {
if(open) getCoordinates(thisPicker);
}, [ open ]);
// Handling Coordinates
const [ coords, setCoords ] = useState(null);
const getCoordinates = ({ current }) => {
if(current) {
const rect = current.getBoundingClientRect();
setCoords({
left: rect.x,
top: rect.y + window.scrollY + 40
});
}
}
useEffect(() => {
window.addEventListener('resize', () => getCoordinates(thisPicker));
return () => window.removeEventListener('resize', () => getCoordinates(thisPicker));
}, []);
// Handle Enter
const handleEnter = useCallback(e => {
if(e.keyCode === 13 && text) {
if(filteredList.length) addTag(filteredList[0]);
else addTag({
id: Math.random(),
title: text
});
setText('');
setOpen(false);
}
}, [ addTag, text, filteredList ]);
useEffect(() => {
let el = thisInput.current;
el.addEventListener('keyup', e => handleEnter(e));
return () => el.removeEventListener('keyup', e => handleEnter(e));
}, [ handleEnter ]);
return(
<div className='tag-picker' ref={thisPicker}>
<input type='text' value={text} onChange={e => setText(e.target.value)} ref={thisInput} />
<div className='selected-tags'>
{ selectedTags.map(tag => (
<Tag tag={tag} key={tag.id} removeTag={removeTag} />
)) }
</div>
{ open ?
<Portal>
<List text={text} items={filteredList} onSelect={addTag} setOpen={setOpen} coords={coords} />
</Portal>
: null }
</div>
);
};
There are some issues with your first useEffect. It has an extra dependency selectedTags.
If you don’t use useCallback to wrap the callback, the effect indeed depends on selectedTags. If you use useCallback, the effect only depends on the callback.
Effectively their meaning are the same. If the dependencies of useCallback change, the callback will also be recalculated. Hence the change of selectedTags triggers the changes of the callback (your getIds) and this triggers the effect: Without useCallback you put the dependencies directly inside the dependencies array of useEffect so their changes directly trigger the effect.

Resources