I've been working on learning React, and in doing so built a todo app using class components. Recently I have been working to make a copy of the todo app using functions and hooks instead of classes.
Having refactored the code everything appears to be working correctly aside from one use case.
CASE 1:
When typing into the input and clicking "Add" button to call addItem() the new todo item adds as expected.
CASE 2:
When typing into the input and hitting enter to trigger an event handler which calls addItem() the value of newItem is always the same as its initial value.
I can't for the life of me figure out why addItem() behaves differently when called from the click of the "Add" button versus the press of the "Enter" key.
TodoAppContainer.js
import React, { useState, useEffect } from 'react';
import TodoList from './TodoList';
import TodoForm from './TodoForm';
import { GenerateID } from './generateId';
export default function TodoListContainer(props) {
const [newItem, setNewItem] = useState('New Todo');
const [items, setItems] = useState([{
name: 'Build Todo List App',
done: true,
key: GenerateID.next().value
}]);
const handleKeyDown = e => {
if (e.key === 'Enter') addItem();
};
const handleChange = ({ target }) => {
console.log("handleChange");
// capture text from input field
const text = target.value;
// update state value for "newItem"
setNewItem(text);
};
const addItem = () => {
console.log("addItem");
// exit early if there is no item
if (!!!newItem.trim()) return;
// build new item to add
const itemToAdd = {
name: newItem,
done: false,
key: GenerateID.next().value
};
// update state with new item
setItems(prevItems => [itemToAdd, ...prevItems]);
// clear text for new item
setNewItem('');
};
const completeItem = key => {
console.log('completeItem');
// create new copy of state items
const updatedItems = [...items];
// get the index of the item to update
const index = updatedItems.findIndex(v => v.key === key);
// toggle the done state of the item
updatedItems[index].done = !updatedItems[index].done;
// update the state
setItems(updatedItems);
};
const removeItem = key => {
console.log('removeItem');
// create copy of filtered items
const filteredItems = items.filter(v => v.key !== key);
// update the state of items
setItems(filteredItems);
}
// get count of items that are "done"
const getTodoCount = () => items.filter(v => v.done).length;
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, []);
return (
<section className='todo-section'>
<TodoForm
newItem={newItem}
handleChange={handleChange}
addItem={addItem}
/>
<TodoList
items={items}
count={getTodoCount()}
onClick={completeItem}
onRemove={removeItem}
/>
</section>
);
}
TodoForm.js
import React from 'react';
import PropTypes from 'prop-types';
export default function TodoForm(props) {
const { newItem, handleChange, addItem } = props;
return (
<div className='todo-form'>
<input type='text' value={newItem} onChange={handleChange} />
<button onClick={addItem}>Add</button>
</div>
)
}
TodoForm.propTypes = {
newItem: PropTypes.string.isRequired,
addItem: PropTypes.func.isRequired,
handleChange: PropTypes.func.isRequired
};
TodoList.js
import React from 'react';
import PropTypes from 'prop-types';
export default function TodoList(props) {
const { items, count, onClick, onRemove } = props;
const shrug = '¯\\_(ツ)_/¯';
const shrugStyles = { fontSize: '2rem', fontWeight: 400, textAlign: 'center' };
const buildItemHTML = ({ key, name, done }) => {
const className = done ? 'todo-item done' : 'todo-item';
return (
<li className={className} key={key}>
<span className='item-name' onClick={() => onClick(key)}>{name}</span>
<span className='remove-icon' onClick={() => onRemove(key)}>✖</span>
</li>
);
};
return (
<div>
<p style={{ margin: 0, padding: '0.75em' }}>{count} of {items.length} Items Complete!</p>
<ul className='todo-list'>
{items.length ? items.map(buildItemHTML) : <h1 style={shrugStyles}>{shrug}<br />No items here...</h1>}
</ul>
</div>
);
};
TodoList.propTypes = {
count: PropTypes.number.isRequired,
items: PropTypes.array.isRequired,
onClick: PropTypes.func.isRequired,
onRemove: PropTypes.func.isRequired
};
This is happening because the you are adding the event listener inside useEffect and at that time the value of newItem is your initial newItem.
To make it work , you can add newItem to dependecy array to update the event listeners, every time newItem updates.
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [newItem]);
This however is just a solution but not the recommended solution. Adding event listeners this way is not very React-ish.
Instead of writing event listeners in useEffect.You should instead bind an event like this
export default function TodoForm(props) {
const { newItem, handleChange, addItem ,handleKeyDown} = props;
return (
<div className='todo-form'>
<input type='text'
value={newItem}
onChange={handleChange}
onKeyDown={handleKeyDown}// this is new
/>
<button onClick={addItem}>Add</button>
</div>
)
}
And don't forget to add it in the parent component
<TodoForm
newItem={newItem}
handleChange={handleChange}
handleKeydown={handleKeydown}//this is new
addItem={addItem}
/>
try to add this to your input:
<input type='text' value={newItem} onChange={handleChange} onKeyDown={(event) => {
if (event.key === "Enter") {
addItem();
}
}}/>
And delete the eventListener from your useEffect() - actually you can delete the whole useEffect() since it only tries to handle your eventListener...
This is a classic case of stale closure. You add the keydown event to the document only once when the component mounts, and it remembers the function only on the first render, when the state is an the default string.
One way to solve it is add newItem as a dependency to your useEffect, you can also use the onKeyDown prop on the input of your new todo, but that might not act as you expect (e.g. the input needs to be focused for the event to fire)
Related
What the code does: It's performing a DOM search based on what's typed in an input (it's searching elements by text). All this is happening in a React component.
import { useEffect, useReducer } from "react";
let elements: any[] = [];
const App = () => {
const initialState = { keyEvent: {}, value: "Initial state" };
const [state, updateState] = useReducer(
(state: any, updates: any) => ({ ...state, ...updates }),
initialState
);
function handleInputChange(event: any) {
updateState({ value: event.target.value });
}
function isCommand(event: KeyboardEvent) {
return event.ctrlKey;
}
function handleDocumentKeyDown(event: any) {
if (isCommand(event)) {
updateState({ keyEvent: event });
}
}
useEffect(() => {
document.addEventListener("keydown", handleDocumentKeyDown);
return () => {
document.removeEventListener("keydown", handleDocumentKeyDown);
};
}, []);
useEffect(() => {
const selectors = "button";
const pattern = new RegExp(state.value === "" ? "^$" : state.value);
elements = Array.from(document.querySelectorAll(selectors)).filter(
(element) => {
if (element.childNodes) {
const nodeWithText = Array.from(element.childNodes).find(
(childNode) => childNode.nodeType === Node.TEXT_NODE
);
if (nodeWithText) {
// The delay won't happenn if you comment out this conditional statement:
if (nodeWithText.textContent?.match(pattern)) {
return element;
}
}
}
}
);
console.log('elements 1:', elements)
}, [state]);
console.log('elemets 2:', elements)
return (
<div>
<input
id="input"
type="text"
onChange={handleInputChange}
value={state.value}
/>
<div id="count">{elements.length}</div>
<button>a</button>
<button>b</button>
<button>c</button>
</div>
);
};
export default App;
The problem: The value of elements outside of useEffect is the old data. For example, if you type a in the input, console.log('elements 1:', elements) will log 1, and console.log('elements 2:', elements) will log 0. Note: there are 3 buttons, and one of them has the text a.
The strange thing is that the problem doesn't happen if you comment out this if-statement:
// The delay won't happenn if you comment out this conditional statement:
if (nodeWithText.textContent?.match(pattern)) {
return element;
}
In this case, if you type anything (since the pattern matching has been commented out), console.log('elements 1:', elements) and console.log('elements 2:', elements) will log 3. Note: there are 3 buttons.
Question: What could be the problem, and how to fix it? I want to render the current length of elements.
Live code:
It's happening because of the elements variable is not a state, so it's not reactive.
Create a state for the elements:
const [elements, setElements] = useState<HTMLButtonElement[]>([])
And use this state to handle the elements.
import { useEffect, useReducer, useState } from "react";
const App = () => {
const initialState = { keyEvent: {}, value: "Initial state" };
const [state, updateState] = useReducer(
(state: any, updates: any) => ({ ...state, ...updates }),
initialState
);
const [elements, setElements] = useState<HTMLButtonElement[]>([])
function handleInputChange(event: any) {
updateState({ value: event.target.value });
}
function isCommand(event: KeyboardEvent) {
return event.ctrlKey;
}
function handleDocumentKeyDown(event: any) {
if (isCommand(event)) {
updateState({ keyEvent: event });
}
}
useEffect(() => {
document.addEventListener("keydown", handleDocumentKeyDown);
return () => {
document.removeEventListener("keydown", handleDocumentKeyDown);
};
}, []);
useEffect(() => {
const selectors = "button";
const pattern = new RegExp(state.value === "" ? "^$" : state.value);
let newElements = Array.from(document.querySelectorAll(selectors)).filter(
(element) => {
if (element.childNodes) {
const nodeWithText = Array.from(element.childNodes).find(
(childNode) => childNode.nodeType === Node.TEXT_NODE
);
if (nodeWithText) {
// The delay won't happenn if you comment out this conditional statement:
if (nodeWithText.textContent?.match(pattern)) {
return element;
}
}
}
}
);
setElements(newElements)
console.log("elements 1:", elements?.length);
}, [state]);
console.log("elemets 2:", elements?.length);
return (
<div>
<input
id="input"
type="text"
onChange={handleInputChange}
value={state.value}
/>
<div id="count">{elements?.length}</div>
<button>a</button>
<button>b</button>
<button>c</button>
</div>
);
};
export default App;
Your useEffect() runs after your component has rendendered. So the sequence is:
You type something into input, that triggers handleInputChange
handleInputChange then updates your state using updateState()
The state update causes a rerender, so App is called App()
console.log('elemets 2:', elements.length) runs and logs elements as 0 as it's still empty
App returns the new JSX
Your useEffect() callback runs, updating elements
Notice how we're only updating the elements after you've rerendered and App has been called.
The state of your React app should be used to describe your UI in React. Since elements isn't React state, it has a chance of becoming out of sync with the UI (as you've seen), whereas using state doesn't have this issue as state updates always trigger a UI update. Consider making elements part of your state. If it needs to be accessible throughout your entire App, you can pass it down as props to children components, or use context to make it accessible throughout all your components.
With that being said, I would make the following updates:
Add elements to your state
Remove your useEffect() with the dependency of [state]. If we were to update the elements state within this effect, then that would trigger another rerender directly after the one we just did for the state update. This isn't efficient, and instead, we can tie the update directly to your event handler. See You Might Not Need an Effect for more details and dealing with other types of scenarios:
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/#babel/standalone/babel.min.js"></script>
<script type="text/babel">
const { useEffect, useReducer} = React;
const App = () => {
const initialState = {keyEvent: {}, value: "Initial state", elements: []};
const [state, updateState] = useReducer(
(state: any, updates: any) => ({ ...state, ...updates}),
initialState
);
function searchDOM(value) {
const selectors = "button";
const pattern = new RegExp(value === "" ? "^$" : value);
return Array.from(document.querySelectorAll(selectors)).filter(
(element) => {
if (element.childNodes) {
const nodeWithText = Array.from(element.childNodes).find(
(childNode) => childNode.nodeType === Node.TEXT_NODE
);
return nodeWithText?.textContent?.match(pattern);
}
return false;
}
);
}
function handleInputChange(event) {
updateState({
value: event.target.value,
elements: searchDOM(event.target.value)
});
}
function isCommand(event) {
return event.ctrlKey;
}
function handleDocumentKeyDown(event) {
if (isCommand(event)) {
updateState({
keyEvent: event
});
}
}
useEffect(() => {
document.addEventListener("keydown", handleDocumentKeyDown);
return () => {
document.removeEventListener("keydown", handleDocumentKeyDown);
};
}, []);
console.log("elements:", state.elements.length);
return (
<div>
<input id="input" type="text" onChange={handleInputChange} value={state.value} />
<div id="count">{state.elements.length}</div>
<button>a</button>
<button>b</button>
<button>c</button>
</div>
);
};
ReactDOM.createRoot(document.body).render(<App />);
</script>
useEffect triggered after react completed its render phase & flush the new changes to the DOM.
In your case you have two useEffects. The first one register your event lister which will then update your component state when input field change. This triggers a state update.( because of the setState )
So React will start render the component again & finish the cycle. And now you have 2nd useEffect which has state in dependency array. Since the state was updated & the new changes are committed to the DOM, react will execute 2nd useEffect logic.
Since your 2nd useEffect just assign some values to a normal variable React will not go re render your component again.
Based on your requirement you don't need a 2nd useEffect. You can use a useMemo,
let elements = useMemo(() => {
const selectors = "button";
const pattern = new RegExp(state.value === "" ? "^$" : state.value);
return Array.from(document.querySelectorAll(selectors)).filter(
(element) => {
if (element.childNodes) {
const nodeWithText = Array.from(element.childNodes).find(
(childNode) => childNode.nodeType === Node.TEXT_NODE
);
if (nodeWithText) {
// The delay won't happenn if you comment out this conditional statement:
if (nodeWithText.textContent?.match(pattern)) {
return element;
}
}
}
})
}, [state])
Note: You don't need to assign your elements into another state. It just create another unwanted re render in cycle. Since you are just doing a calculation to find out the element array you can do it with the useMemo
I was trying to access Parent state when a function is called from Child component, for that created a function in Parent component and passed it to Child, issue is I am not able to access the state completely.
for example on button click I add a new input field and a delete button, suppose I added 10 input fields, and added all of them in state array, but when i click delete button of second input field, the count I get from state is 1, similar if I click 5th delete button i get count as 4 and it only show me 4 items in state, but it has 10 items
Here is an example link https://codesandbox.io/s/add-react-component-onclick-forked-t2i0ll?file=/src/index.js:0-869
import React, { useState } from "react";
import ReactDOM from "react-dom";
const Input = ({ deleteRow, position }) => {
const handleDelete = () => deleteRow(position);
return (
<>
<input placeholder="Your input here" />
<button onClick={handleDelete}>Delete</button>
</>
);
};
const Form = () => {
const [inputList, setInputList] = useState([]);
const onDeleteRow = (position) => {
console.log("inputCount", inputList);
};
const onAddBtnClick = (event) => {
setInputList(
inputList.concat(
<Input
key={inputList.length}
position={inputList.length}
deleteRow={onDeleteRow}
/>
)
);
};
return (
<div>
<button onClick={onAddBtnClick}>Add input</button>
{inputList}
</div>
);
};
ReactDOM.render(<Form />, document.getElementById("form"));
It's called a stale closure.
You can avoid that by not storing react elements inside the state.
Example:
const Form = () => {
const [inputList, setInputList] = useState([]);
const onDeleteRow = (position) => {
console.log("inputCount", inputList);
};
const onAddBtnClick = (event) => {
setInputList([...inputList, inputList.length])
);
};
return (
<div>
<button onClick={onAddBtnClick}>Add input</button>
{inputList.map(item => (
<Input
key={item}
position={item}
deleteRow={onDeleteRow}
/>
)}
</div>
);
};
I am making a To Do App using useState react hook.
I have complete with Create Read and Delete parts but
I have not been able to update the state.
Can somebody please help me.
I have complete the same with Class component.
/****************************** MY app.js file ********************************************/
import React, { useState } from "react";
import "./App.css";
import ToDoList from "./Components/ToDoList";
function App() {
const [change, handleChange] = useState("");
const [items, addItem] = useState([]);
let handleSubmit = (e) => {
e.preventDefault();
// console.log(change)
if (change !== "") {
addItem([...items, { text: change, key: Date.now() }]);
handleChange("");
}
};
let removeTask = (key) => {
let item = items.filter((ele) => {
return ele.key !== key;
});
console.log(item);
addItem([...item]);
};
let updateToDo = (value, key) => { // <<<<<<< I need to make changes in this piece of code.
let allItem = items.map((e) => {
if (e.key === key) {
e.text = value;
}
console.log(...allItem);
// addItem([...items, { allItem }]);
});
};
return (
<div className="toDoContainer">
<form onSubmit={handleSubmit}>
<input
type="text"
onChange={(e) => handleChange(e.target.value)}
value={change}
placeholder="Add Item"
/>
<button>Add Item</button>
</form>
<ToDoList items={items} removeTask={removeTask} updateToDo={updateToDo} />
</div>
);
}
export default App;
/*************************************** My ToDoList.js *************************************/
import React from "react";
import "./ToDoList.css";
function ToDoList({ items, removeTask, updateToDo }) {
let toDoItems = items.map((item) => {
return (
<div className="toDoItems" key={item.key}>
<p>
<input
type="text"
id = {item.key}
value={item.text}
onChange={(e) => updateToDo(e.target.value, item.key)}
/>
<span onClick={() => removeTask(item.key)}>✘</span>
</p>
</div>
);
});
return <div>{toDoItems}</div>;
}
export default ToDoList;
You can map items into new array and when the item key matches the key parameter update the text property.
let updateToDo = (value, key) => {
const allItem = items.map(item => {
const newItem = {...item};
if (item.key === key) {
newItem.text = value;
}
return newItem;
});
console.log(...allItem);
addItem(allItem);
};
buddy!
First fo all, I suggest you can read document about React Hooks, it have clear explain how to update State when you using useState, I split several parts below:
On here const [items, addItem] = useState([]);, The Hooks useState will return a array, the first item is your value of state, at this time is a empty array [], the second item is a method which can update value of state.
Next, in your update method updateToDo, you used map to update original value of state and create the new value of state. so why didn't you call addItem to update your value of state?(Maybe you tried, but I have no idea for why you comment out that line?)
You just need to pass new value of state for addItem, and I suggest you can rename it to setItem instead of addItem.
You can following:
let updateToDo = (value, key) => { // <<<<<<< I need to make changes in this piece of code.
let allItem = items.map((e) => {
if (e.key === key) {
e.text = value;
}
addItem(allItem);
});
};
EDIT: See the comment of O.o for the explanation of the answer and the variant in case you are using classes.
I've come across to something and I can't find the solution.
I have 4 components in my web app:
Parent
child_1
child_2
child_3
I have a button on the Parent, and different forms (with inputs, checkboxes and radiobuttons) at the children.
Each child has his own button that executes several functions, some calculations, and updates the corresponding states. (No states are passed through parent and child).
I need to replace the three buttons of the children with the parent button.
Is there a way that I can execute the functions at the three children from the parent button and retrieve the results? (the results are one state:value per child.)
function Child1(props) {
const [value, setValue] = useState("");
useEffect(() => {
calculate();
}, [props.flag]);
calculate() {
//blah blah
}
onChange(e) {
setValue(e.target.value);
props.onChange(e.target.value); // update the state in the parent component
}
return (
<input value={value} onChange={(e) => onChange(e)} />
);
}
function Parent(props) {
const [flag, setFlag] = useState(false);
const [child1Value, setChild1Value] = useState("");
return (
<div>
<Child1 flag={flag} onChange={(value) => setChild1Value(value)}/>
<button onClick={() => setFlag(!flag)} />
</div>
);
}
I didn't test this but hope this helps you. And lemme know if there is an issue.
Try the following:
create refs using useRef for child form components.
for functional components, in order for the parent to access the child's methods, you need to use forwardRef
using the ref, call child component functions on click of parent submit button (using ref.current.methodName)
See the example code. I have tested it on my local, it is working ok.
Parent
import React, { Fragment, useState, useRef } from "react";
import ChildForm1 from "./ChildForm1";
const Parent = props => {
const [form1Data, setFormData] = useState({});//use your own data structure..
const child1Ref = useRef();
// const child2Ref = useRef(); // for 2nd Child Form...
const submitHandler = e => {
e.preventDefault();
// execute childForm1's function
child1Ref.current.someCalculations();
// execute childForm2's function
// finally do whatever you want with formData
console.log("form submitted");
};
const notifyCalcResult = (calcResult) => {
// update state based on calcResult
console.log('calcResult', calcResult);
};
const handleChildFormChange = data => {
setFormData(prev => ({ ...prev, ...data }));
};
return (
<Fragment>
<h1 className="large text-primary">Parent Child demo</h1>
<div>
<ChildForm1
notifyCalcResult={notifyCalcResult}
ref={child1Ref}
handleChange={handleChildFormChange} />
{/*{do the same for ChildForm2 and so on...}*/}
<button onClick={submitHandler}>Final Submit</button>
</div>
</Fragment>
);
};
export default Parent;
ChildFormComponent
import React, { useState, useEffect, forwardRef, useImperativeHandle } from "react";
const ChildForm1 = ({ handleChange, notifyCalcResult }, ref) => {
const [name, setName] = useState("");
const [calcResult, setCalcResult] = useState([]);
const someCalculations = () => {
let result = ["lot_of_data"];
// major calculations goes here..
// result = doMajorCalc();
setCalcResult(result);
};
useImperativeHandle(ref, () => ({ someCalculations }));
useEffect(() => {
// notifiy parent
notifyCalcResult(calcResult);
}, [calcResult]);
return (
<form className="form">
<div className="form-group">
<input
value={name}// //TODO: handle this...
onChange={() => handleChange(name)}//TODO: notify the value back to parent
type="text"
placeholder="Enter Name"
/>
</div>
</form>
);
};
export default forwardRef(ChildForm1);
Also as a best practice, consider to maintain state and functions in the parent component as much as possible and pass the required values/methods to the child as props.
So I've been at this all morning and can't figure out how to update my state correctly using useState.
I have a single controlled user input with name. When a user enters text and submits I would like to take the input value and push that to another state object namesList and map over that array in a child component.
Parent Component
import React, { Fragment, useState } from 'react';
import TextField from '#material-ui/core/TextField';
import NameInputList from './NameInputList';
const NameInputContainer = () => {
const [name, setName] = useState('');
const [namesList, setNamesList] = useState([]);
const handleChange = event => {
const { value } = event.target;
setName(value);
};
const handleSubmit = event => {
event.preventDefault();
setNamesList(prevState => [...prevState, name]);
setName('');
};
return (
<Fragment>
<form onSubmit={handleSubmit}>
<TextField
id="name"
label="Enter New Name"
variant="outlined"
value={name}
onChange={handleChange}
/>
</form>
{namesList.length > 0 && <NameInputList names={namesList} />}
</Fragment>
);
};
export default NameInputContainer;
Child Component
import React from 'react';
import PropTypes from 'prop-types';
const NameInputList = ({ names }) => {
console.log('child component names: ', names);
const generateKey = val => {
return `${val}_${new Date().getTime()}`;
};
return (
<ul>
{names.map((name, index) => ( // <--- Adding the index here seems to resolve the problem. I want to say the error was happening based on issues with having Unique keys.
<li key={generateKey(name + index)}>{name}</li>
))}
</ul>
);
};
NameInputList.propTypes = {
names: PropTypes.arrayOf(PropTypes.string)
};
NameInputList.defaultProps = {
names: []
};
export default NameInputList;
Seems like when I submit the first time the child component gets the correct value and renders as expected. When I go to input a new name there's a rerender on every handleChange. I'm not sure how to consistently: enter text > submit input > update namesList > render updated namesList in child component without handleChange breaking the functionality.
Just adding to that, as namesList prop is an array, a new copy will be sent for each parent re-render. Hence it's comparison will always be false and React will trigger a re-render of the child.
You can prevent the re-render by doing this :
export default React.memo(NameInputList, function(prevProps, nextProps) {
return prevProps.names.join("") === nextProps.names.join("");
});
This will ensure that NameInputList only re-renders when the contents of namesList actually change.