I've dug myself into a deep rabbit hole with this component in an attempt to use React hooks.
The Parent component handles a dictionary state which is eventually distributed to multiple components.
My problem child component WordInput has a form with a single input. When submitting the form the component is fetching the word's definition from an API and passing on both the word and the definition to the parent which then sets the state in the form of dictionary. So far, so good IF it's the first word in dictionary. The part I'm having trouble with is to submit any subsequent words/definitions.
When the user submits a subsequent word, I want the component to check whether the word already exists in the dictionary that is passed to the child. If it doesn't exist, add it to the dictionary via the submit function.
I think the problem is that I'm trying to do too much with useEffect
I useEffect to:
- set loading
- check and process the dictionary for existing words
- check that definition and word aren't empty and submit both to parent/dictionary
- fetch a definition from an API
In the unprocessed code, I have multiple console.groups to help me keep track of what is happening. The more I add to the component, the more Subgroups and subgroups of subgroups accumulate. Clearly, the approach I'm taking isn't very dry and causes too many re-renders of the component/useEffect functions. For conciseness, I have taken out the console.log entries.
The imported fetchWordDefinition merely processes the fetched data and arranges it correctly into an array.
I don't know how to keep this dry and effective, and any help is appreciated with this rather simple task. My hunch is to keep all the logic to submit the word/definition in the submit handler, and only use useEffect to validate the data prior to that.
import React, { useState, useEffect } from "react";
import fetchWordDefinition from "./lib/utils";
const WordInput = ({ onSubmit, dictionary }) => {
const [definition, setDefinition] = useState([]);
const [cause, setCause] = useState({ function: "" });
const [error, setError] = useState({});
const [loading, setLoading] = useState(false);
const [word, setWord] = useState("");
const [wordExistsInDB, setWordExistsInDB] = useState(false);
useEffect(() => {
const dictionaryEmpty = dictionary.length === 0 ? true : false;
if (dictionaryEmpty) {
return;
} else {
for (let i = 0; i < dictionary.length; i += 1) {
if (dictionary[i].word === word) {
setWordExistsInDB(true);
setError({ bool: true, msg: "Word already exists in DB" });
break;
} else {
setWordExistsInDB(false);
setError({ bool: false, msg: "" });
}
}
}
}, [dictionary, word]);
useEffect(() => {
const definitionNotEmpty = definition.length !== 0 ? true : false;
const wordNotEmpty = word !== "" ? true : false;
if (wordNotEmpty && definitionNotEmpty && !wordExistsInDB) {
onSubmit(word, definition);
setWord("");
setDefinition([]);
}
}, [definition, word, onSubmit, wordExistsInDB]);
useEffect(() => {
if (cause.function === "fetch") {
async function fetchFunction() {
const fetch = await fetchWordDefinition(word);
return fetch;
}
fetchFunction().then(definitionArray => {
setDefinition(definitionArray);
setCause({ function: "" });
});
}
}, [cause, word]);
const handleSubmit = async e => {
e.preventDefault();
setLoading(true);
setCause({ function: "fetch" });
};
return (
<form onSubmit={handleSubmit}>
{error.bool ? <span>{error.msg}</span> : null}
<input
name='word'
placeholder='Enter Word'
type='text'
value={word}
onChange={({ target: { value } }) => setWord(value)}
/>
<input type='submit' />
</form>
);
};
export default WordInput;
There are indeed more useEffect's happening than necessary, as well as most of the state. All you need is the handleSubmit to do the fetching.
const WordInput = ({ onSubmit, dictionary }) => {
const [word, setWord] = React.useState("");
const handleChange = React.useCallback(e => {
setWord(e.currentTarget.value)
}, [])
const handleSubmit = React.useCallback(() => {
//check if word is in dictionary
const wordIsAlreadyThere = dictionary.map(entry => entry.word).includes(word)
//fetch the definition, wait for it, and call submit
if(!wordIsAlreadyThere && word.length > 0){
fetchWordDefinition(word)
.then(definition => {
onSubmit(word, definition)
setWord('')
}).catch(err => console.log(err))
}
}, [])
return (
<form onSubmit={handleSubmit}>
<input
value={word}
onChange={handleChange}/>
<input type='submit' />
</form>
);
}
I think you're missing out on some clarity and what useEffect is for
A functional component gets re-ran everytime either a prop or a state changes. useEffect runs when the component gets created, and we use it for things like doing a first-time fetch, or subscribing to an event handler. The second argument (array of variables) is used so that, if we have for example a blog post with with comments etc, we don't re-fetch everything unless the ID changes (meaning it's a new blog post)
Looking at your code, we have this flow:
User inputs something and hits Submit
Check if the word exists in a dictionary
a. If it exists, display an error message
b. If it doesn't exist, fetch from an API and call onSubmit
So really the only state we have here is the word. You can just compute an error based on if the word is in the dictionary, and the API call is done in a callback (useCallback). You have a lot of extra state that doesn't really matter in a state-way
A simplified version would look like this
const WordInput = ({ onSubmit, dictionary }) => {
const [word, setWord] = useState("")
const [loading, setLoading] = useState(false)
// `find` will find the first entry in array that matches
const wordExists = !!dictionary.find(entry => entry.word === word)
// Ternary operator,
const error = (wordExists) ? "Word already exists in DB" : null
// When user hits submit
const handleSubmit = useCallback(() => {
if (wordExists || !word.length) return;
setLoading(true)
fetchFunction()
.then(definitionArray => {
onSubmit(word, definitionArray)
})
}, [])
return (
<form onSubmit={handleSubmit}>
{error && <span>{error}</span>}
<input
name='word'
placeholder='Enter Word'
type='text'
value={word}
onChange={({ target: { value } }) => setWord(value)}
/>
<input type='submit' onclick={handleSubmit} disabled={wordExists}/>
</form>
);
};
Your component only needs to keep track of the word and the loading flag.
When the user changes the word input it updates the word state.
When the user submits the form the loading state changes. This triggers a useEffect that will first check if the word already exists. If not it proceeds to fetch it and add both the word and its definition to the dictionary.
const WordInput = ({ onSubmit, dictionary }) => {
const [loading, setLoading] = useState(false);
const [word, setWord] = useState("");
useEffect(() => {
if (!loading) return;
const existing_word = dictionary.find(item => item.word === word);
if (existing_word) return;
const fetchFunction = async () => {
const definition = await fetchWordDefinition(word);
// Update the dictionary
onSubmit(word, definition);
// Reset the component state
setWord("");
setLoading(false);
};
fetchFunction();
}, [loading]);
return (
<form
onSubmit={e => {
e.preventDefault();
if (word.length) {
setLoading(true);
}
}}
>
<input
name="word"
placeholder="Enter Word"
type="text"
value={word}
onChange={({ target: { value } }) => setWord(value)}
/>
<input type="submit" />
</form>
);
};
Please let me know if something is not clear or I missed something.
Related
What I have is a list that was fetched from an api. This list will be filtered based on the input. But at the first render it will render nothing, unless I press space or add anything to the input. Another solution is set the fetched data to the filteredList. But I don't know if it is the right thing to set the fetched data to two arrays.
import React, { useState, useEffect } from "react";
const PersonDetail = ({ person }) => {
return (
<div>
Id: {person.id} <br />
Name: {person.name} <br />
Phone: {person.phone}
</div>
);
};
const App = () => {
const [personsList, setPersonsList] = useState([]);
const [personObj, setPersonObj] = useState({});
const [showPersonDetail, setShowPersonDetail] = useState(false);
const [newPerson, setNewPerson] = useState("");
const [filter, setFilter] = useState("");
const [filteredList, setFilteredList] = useState(personsList);
useEffect(() => {
fetch("https://jsonplaceholder.typicode.com/users")
.then((response) => response.json())
.then((data) => {
setPersonsList(data);
//setFilteredList(data) <-- I have to add this to work
console.log(data);
});
}, []);
const handleClick = ({ person }) => {
setPersonObj(person);
if (!showPersonDetail) {
setShowPersonDetail(!showPersonDetail);
}
};
const handleChange = (event) => {
setNewPerson(event.target.value);
};
const handleSubmit = (event) => {
event.preventDefault();
const tempPersonObj = {
name: newPerson,
phone: "123-456-7890",
id: personsList.length + 1,
};
setPersonsList((personsList) => [...personsList, tempPersonObj]);
//setFilteredList(personsList) <-- to render the list again when add new person
setNewPerson(" ");
};
const handleFilter = (event) => {
setFilter(event.target.value);
const filteredList =
event.target.value.length > 0
? personsList.filter((person) =>
person.name.toLowerCase().includes(event.target.value.toLowerCase())
)
: personsList;
setFilteredList(filteredList);
};
return (
<div>
<h2>List:</h2>
Filter{" "}
<input value={filter} onChange={handleFilter} placeholder="Enter" />
<ul>
{filteredList.map((person) => {
return (
<li key={person.id}>
{person.name} {""}
<button onClick={() => handleClick({ person })}>View</button>
</li>
);
})}
</ul>
<form onSubmit={handleSubmit}>
<input
placeholder="Add Person"
value={newPerson}
onChange={handleChange}
/>
<button type="submit">Add</button>
</form>
{showPersonDetail && <PersonDetail person={personObj} />}
</div>
);
};
export default App;
Your filtered list is actually something derived from the full persons list.
To express this, you should not create two apparently independent states in this situation.
When your asynchronous fetch completes, the filter is probably already set and you are just setting personsList which is not the list you are rendering. You are rendering filteredList which is still empty and you are not updating it anywhere, except when the filter gets changed.
To avoid all of this, you could create the filtered list on each rendering and — if you think this is not efficient enough — memoize the result.
const filteredList = useMemo(() =>
filter.length > 0
? personsList.filter((person) =>
person.name.toLowerCase().includes(filter.toLowerCase())
)
: personsList,
[filter, personsList]
);
When the filter input gets changed, you should just call setFilter(event.target.value).
This way, you will always have a filtered list, independent of when your asynchronous person list fetching completes or when filters get updated.
Side note: Writing const [filteredList, setFilteredList] = useState(personsList); looks nice but is the same as const [filteredList, setFilteredList] = useState([]); because the initial value will be written to the state only once, at that's when the component gets initialized. At that time personsList is just an empty array.
I am currently having trouble thinking of an efficient solution to my problem. Essentially, I have a search bar on the previous page that allows a user to enter in a city location (heropage.js). That then navigates to a concerts list page (selectconcert.js), which portrays all of the concerts from that city using the useParams() hook to take the city name from the url.
The problem I am having is the following: I want users to be able to look up a new city on the selectconcerts.js page if they would like, and clear the useParams() so it doesn't interrupt. Currently since I am using a useEffect hook with the useParams, I am sometimes getting the old location param interrupting with the new search query for city. I also feel like I am duplicating my code by having a fetchConcerts function in my useEffect hook and having a handleLocationSubmit function to process the separate queries (depending if it was searched on the prior page or current page). I dabbled into using Searchparams but please let me know if you may have a more efficient solution.
selectconcert.js (page portraying the list of concerts)
export default function Concerts() {
let navigate = useNavigate()
const [concerts, setConcerts] = useState([]);
const [city, setCity] = useState('');
const [artist, setArtist] = useState('');
const [toggled, setToggled] = useState(false);
const [invalid, setInvalid] = useState(false);
const [page, setPage] = useState(1)
let {user} = useContext(AuthContext)
const yesterday = ( d => new Date(d.setDate(d.getDate()-1)) )(new Date());
let { location } = useParams();
useEffect( () => {
const fetchConcerts = async() => {
const concertResponse = await fetch(`${process.env.REACT_APP_BUDDY_API}/api/selectconcertsforcity/${location}/&p=${page}`)
if(concertResponse.ok) {
const concertData = await concertResponse.json();
if (concertData.concerts.setlist) {
for (let i in concertData.concerts.setlist){
const dateParts = concertData.concerts.setlist[i].eventDate.split("-");
const dateObject = new Date(+dateParts[2], dateParts[1] - 1, +dateParts[0]);
concertData.concerts.setlist[i].eventDate = dateObject
}
setConcerts(concertData.concerts.setlist);
let concList = [concertData.concerts.setlist.filter(concert => ((concert.eventDate)) >= (Date.now()))]
if (concList[0].length === 0){
setConcerts(0)
}
setArtist('');
setInvalid(false)
} else {
if (location !== undefined) {
setInvalid(true)
setConcerts([])
}
}
}
}
fetchConcerts();
}, [location, page]
);
const handleLocationSubmit = async (e) => {
e.preventDefault();
const city_new = city.split(' ')
let final_city = city_new[0]
for (let i = 1; i < city_new.length; i++) {
final_city += '%20'
final_city += city_new[i]
}
const concertResponse = await fetch(`${process.env.REACT_APP_BUDDY_API}/api/selectconcertsforcity/${final_city}/&p=1`)
if(concertResponse.ok) {
const concertData = await concertResponse.json();
if (concertData.concerts.setlist) {
for (let i in concertData.concerts.setlist){
const dateParts = concertData.concerts.setlist[i].eventDate.split("-");
const dateObject = new Date(+dateParts[2], dateParts[1] - 1, +dateParts[0]);
concertData.concerts.setlist[i].eventDate = dateObject
}
setConcerts(concertData.concerts.setlist);
let concList = [concertData.concerts.setlist.filter(concert => ((concert.eventDate)) >= (Date.now()))]
if (concList[0].length === 0){
setConcerts(0)
}
setArtist('');
setInvalid(false)
} else {
console.error('concertData:', concertResponse);
setInvalid(true)
setConcerts([])
}
}
}
.....
return (
<>
<div className='selectconcerts'>
<div>
<Toggle onChange={(e) => setToggled(e.target.checked)} />
<p> Search by {toggled ? "Artist": "City "}</p>
<div className='entry'>
{ toggled ?
<form onSubmit={handleArtistSubmit}>
<input className="form-control" type="text" value={artist} required onChange={(e) => {setArtist(e.target.value)}} onKeyPress={handleKeypress}/>
</form>
:
<form onSubmit={handleLocationSubmit}>
<input className="form-control" type="text" value={city} required onChange={(e) => {setCity(e.target.value)}} onKeyPress={handleKeypress}/>
</form>
}
<div>
<p></p>
</div>
herosection.js (page with the initial city search query option)
function HeroSection() {
let {user} = useContext(AuthContext)
const [city, setCity] = useState('');
let videoimage = require('./Images/video-3.mp4')
let navigate = useNavigate()
const handleImGoingSubmit = async (e) => {
e.preventDefault();
navigate(`/selectconcerts/${city}`)
}
const handleKeypress = e => {
//it triggers enter button by pressing the enter key
if (e.keyCode === 13) {
handleImGoingSubmit();
}
};
return (
<div className='hero-container'>
<video src={videoimage} autoPlay loop muted />
<h1 align="center">ADVENTURE AWAITS</h1>
{user ? (<p align="center">Hello {user.username}, what are you waiting for?</p>):(<p align="center">What are you waiting for?</p>)}
<div className='hero-btns'>
<form onSubmit={handleImGoingSubmit}>
<input className="form-control" type="text" placeholder="Search concerts by city..." value={city} required onChange={(e) => setCity(e.target.value)} onKeyPress={handleKeypress}/>
</form>
</div>
</div>
);
}
export default HeroSection;
You've already a function fetchConcerts and effect for fetching data based on the location. Don't duplicate this logic (DRY Principle). The handleLocationSubmit callback should issue an imperative navigation action to the same route with the new location path parameter.
Example:
export default function Concerts() {
const navigate = useNavigate();
...
const { location } = useParams();
useEffect(() => {
const fetchConcerts = async () => {
const concertResponse = await fetch(`${process.env.REACT_APP_BUDDY_API}/api/selectconcertsforcity/${location}/&p=${page}`);
...
};
fetchConcerts();
}, [location, page]);
const handleLocationSubmit = (e) => {
e.preventDefault();
const location = city.replaceAll(" ", "%20");
// reset back to page 1
setPage(1);
// navigate to update `location` path param and trigger rerender
navigate(`./${location}`);
};
...
I was looking through your entire code. Including the answer to your questions, I will also suggest some improvements to other pieces of code.
For your main question, you can either add a form reset button that uses form.reset() method to get the input field back to "". But, since in your case, your input is a controlled one(uses state as its value and setState as its handler), you can do the following:
<input {...props} /> // Input for selecting city
<button onClick={() => {
setConcerts([]);
setCity("");
setArtist(" ");
setInvalid(false);
setPage(1);
// Maybe you don't want to reset the toggled state for UX purposes
}}>Reset Search</button>
The fact you mention about you repeating your code is correct, so you should extract out the input field for city, related handlers like handleLocationSubmit and the states you use with it outside of the given file, into a new component. Also, depending on how yo extract out your component, the reset form handler might have fewer setter calls. You might also have to adjust the flow of data though, which I can help you with, if you need it.
In the first few lines of the handleLocationSubmit function, you can replace the white spaces with "%20" using this:
const cityString = city.split(" ").join("%20");
Hope this helps, let me know about any doubts in the comments and I will answer them.
i am a bit puzzled with the logic when reading the below code, although the code is working but not exactly as i would like it to behave.
3 queries i have if some one can please clarify.
1- As i understand useEffect is used to invoke the function after render, but in the below code, once the form is sumbitted (onSubmit={credentialVerify}) it will call the credentialVerify() function as below, so i dont think we need useEffect here, but still the code doesnt call the API unless i use the useEffect statement.
2- Also doesnt wait for me to enter my credentails first and as soon as i go to the Signin page it will fetch the API’s (when using useEffect ) and shows the result in the windows, but i try to design in a way that when i click button then it will fetch the API
3- when in the form onsubmit call the credentialVerify function, i have console.log(e) but it is showing as undefined, but as i understand onsubmit will call the function and through the event argument by default.
Below is the snippet of my code.
Any help Appreciated.
import React, { useState, useEffect } from "react";
import "../App.css";
import { Link } from "react-router-dom";
function Signin() {
const [name, setName] = useState("");
const [password, setPassword] = useState("");
const updateName = (e) => {
setName(e.target.value);
};
const updatePassword = (e) => {
setPassword(e.target.value);
};
const [items, setItems] = useState([]);
useEffect(() => { //Point-1 useEffect- API not call atall without this statement
credentialVerify();
}, []);
const credentialVerify = async (e) => {
console.log(e); //Point-3 this is coming as undefined
const data1 = await fetch("http://localhost:5000/api/customers");
const incomingdata = await data1.json();
console.log(data1);
console.log(incomingdata);
console.log(name, password);
setItems(incomingdata);
};
return (
<div>
<div>
{
<form className="formstyle" onSubmit={credentialVerify}>
<input
type="text"
placeholder="Username"
name="username"
value={name}
onChange={updateName}
/>
<input
type="text"
placeholder="Password"
name="password"
value={password}
onChange={updatePassword}
/>
<button type="submit">Submit</button>
</form>
}
</div>
<div>
{items.map((entry) => {
let key = entry.email;
let valuefirst = entry.firstName;
let valuelast = entry.created_at;
return (
<p key={key}>
{key}: {valuefirst}bb {valuelast}
</p>
);
})}
</div>
</div>
);
}
export default Signin;
For your first question, you are correct - it doesn't make sense to call credentialVerify when your component renders for the first time since that seems to be the handler for when your form gets submitted. Unless you're fetching data prior to displaying your form, you can drop the useEffect hook entirely.
This is also takes care of your second question because the hook will run once when your component renders for the first time, which is indicated by the empty array [] used as a dependency array of the useEffect hook. This is equivalent to componentDidMount in a class-based component, but again, it doesn't make sense to call credentialVerify at this point.
As for your third question, you should probably do something like the following:
const credentialVerify = event => {
event.preventDefault();
(async () => {
const data = await fetch("http://localhost:5000/api/customers")
.then(res => res.json());
.catch(e => e);
console.log(incomingData);
// ...
})();
}
Since you're passing an asynchronous function as your event handler, you might have issues accessing the SyntheticEvent object due to the reasons stated in React docs:
The SyntheticEvent is pooled. This means that the SyntheticEvent object will be reused and all properties will be nullified after the event callback has been invoked. This is for performance reasons. As such, you cannot access the event in an asynchronous way.
reactjs.org/docs/events.html#event-pooling
Your final component should look like the following:
function Signin() {
const [name, setName] = useState("");
const [password, setPassword] = useState("");
const [items, setItems] = useState([]);
const updateName = e => {
setName(e.target.value);
};
const updatePassword = e => {
setPassword(e.target.value);
};
const credentialVerify = event => {
event.preventDefault();
(async () => {
const incomingdata = await fetch("http://localhost:5000/api/customers")
.then(res => res.json())
.catch(e => e);
console.log(incomingdata);
console.log(name, password);
setItems(incomingdata);
})();
};
return (
<div>...</div>
);
}
I have an input in a react component to store a name:
<input key="marker-name" id="marker-name" name="marker-name" onChange={handleRename} type="text" value={name} />
I have written the following handler for it:
const handleRename = ({ target }) => {
setPerception({
...perception,
name: target.value
})
}
However, it's not working as expected, if a user tries to delete the existing name then as soon as the last character in the input is deleted (i.e. the input is empty) the previous value just pops back in.
Here is the full code of the component:
import React, { useState, useEffect } from 'react'
// import custom styles for child component
import './styles.scss'
const MarkerName = ({ store, onStoreUpdate, callbackFunction }) => {
const [clicked, setClicked] = useState(false)
const [perception, setPerception] = useState(null)
const [currentMarkerName] = useState(store.currentMarkerName)
const [currentMarkerForce] = useState(store.currentMarkerForce)
const [currentForce] = useState(store.currentForce)
// A copy of the store to capture the updates
const newStore = store
// Only populate the perception state if it's store value exists
useEffect(() => {
store.perception && setPerception(store.perception)
}, [])
// Only show the form to non-umpire players who cannot see the correct name
const clickHander = () =>
currentForce !== 'umpire' &&
currentForce !== currentMarkerForce &&
setClicked(true)
const handleRename = ({ target }) => {
setPerception({
...perception,
name: target.value
})
newStore.perception.name = target.value
onStoreUpdate(newStore)
}
const handleSubmit = e => {
e && e.preventDefault()
callbackFunction(newStore)
}
const handleRevert = e => {
e.preventDefault()
setPerception({
...perception,
name: null
})
newStore.perception.name = null
onStoreUpdate(newStore)
handleSubmit()
}
const name = perception && perception.name ? perception.name : currentMarkerName
return (
<>
<h2 key="header" onClick={clickHander}>{name}</h2>
{
clicked &&
<div className="input-container marker-name">
<label>
Update asset name
<input key="marker-name" id="marker-name" name="marker-name" onChange={handleRename} type="text" value={name} />
</label>
<button type="submit" onClick={handleSubmit}>Rename</button>
<button onClick={handleRevert}>Revert</button>
</div>
}
</>
)
}
export default MarkerName
As far as I can tell this line is the problem:
const name = perception && perception.name ? perception.name : currentMarkerName;
You are re-rendering on every character change (onChange={handleRename}). As soon as all characters are deleted perception && perception.name is evaluated to true && false (empty strings are falsy) which is false. So the component is rendered with const name = currentMarkerName. As currentMarkerName hasn't changed yet, it is re-rendered with the old name.
Use this instead:
const name = perception && typeof perception.name !== 'undefined' ? perception.name : currentMarkerName;
In React forms are controlled components, You were almost getting it but at that point where you checked for the value of perception before assigning to the inputValue...that does not seem right.
Could you try to make these changes and let us see the effect:
1. For the state value of perception, make the initial value any empty string:
const [perception, setPerception] = useState(null)
On the forminput use
The handleRename function could just be declared as
const handleRename = (e) => {e.target.name: e.target.value}
I'm trying to create an input field that has its value de-bounced (to avoid unnecessary server trips).
The first time I render my component I fetch its value from the server (there is a loading state and all).
Here is what I have (I omitted the irrelevant code, for the purpose of the example).
This is my debounce hook:
export function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(handler);
}, [value, delay]);
return debouncedValue;
}
(I got this from: https://usehooks.com/useDebounce/)
Right, here is my component and how I use the useDebounce hook:
function ExampleTitleInput(props) {
const [title, setTitle] = useState(props.title || "");
const [lastCommittedTitle, setLastCommittedTitle] = useState(title);
const [commitsCount, setCommitsCount] = useState(0);
const debouncedTitle = useDebounce(title, 1000);
useEffect(() => {
setTitle(props.title || "");
}, [props.title]);
useEffect(() => {
if (debouncedTitle !== lastCommittedTitle) {
setLastCommittedTitle(debouncedTitle);
setCommitsCount(commitsCount + 1);
}
}, [debouncedTitle, lastCommittedTitle, commitsCount]);
return (
<div className="example-input-container">
<input
type="text"
value={title}
onChange={e => setTitle(e.target.value)}
/>
<div>Last Committed Value: {lastCommittedTitle}</div>
<div>Commits: {commitsCount}</div>
</div>
);
}
Here is the parent component:
function App() {
const [title, setTitle] = useState("");
useEffect(() => {
setTimeout(() => setTitle("This came async from the server"), 2000);
}, []);
return (
<div className="App">
<h1>Example</h1>
<ExampleTitleInput title={title} />
</div>
);
}
When I run this code, I would like it to ignore the debounce value change the first time around (only), so it should show that the number of commits are 0, because the value is passed from the props. Any other change should be tracked. Sorry I've had a long day and I'm a bit confused at this point (I've been staring at this "problem" for far too long I think).
I've created a sample:
https://codesandbox.io/s/zen-dust-mih5d
It should show the number of commits being 0 and the value set correctly without the debounce to change.
I hope I'm making sense, please let me know if I can provide more info.
Edit
This works exactly as I expect it, however it's giving me "warnings" (notice dependencies are missing from the deps array):
function ExampleTitleInput(props) {
const [title, setTitle] = useState(props.title || "");
const [lastCommittedTitle, setLastCommittedTitle] = useState(title);
const [commitsCount, setCommitsCount] = useState(0);
const debouncedTitle = useDebounce(title, 1000);
useEffect(() => {
setTitle(props.title || "");
// I added this line here
setLastCommittedTitle(props.title || "");
}, [props]);
useEffect(() => {
if (debouncedTitle !== lastCommittedTitle) {
setLastCommittedTitle(debouncedTitle);
setCommitsCount(commitsCount + 1);
}
}, [debouncedTitle]); // removed the rest of the dependencies here, but now eslint is complaining and giving me a warning that I use dependencies that are not listed in the deps array
return (
<div className="example-input-container">
<input
type="text"
value={title}
onChange={e => setTitle(e.target.value)}
/>
<div>Last Committed Value: {lastCommittedTitle}</div>
<div>Commits: {commitsCount}</div>
</div>
);
}
Here it is: https://codesandbox.io/s/optimistic-perlman-w8uug
This works, fine, but I'm worried about the warning, it feels like I'm doing something wrong.
A simple way to check if we are in the first render is to set a variable that changes at the end of the cycle. You could achieve this using a ref inside your component:
const myComponent = () => {
const is_first_render = useRef(true);
useEffect(() => {
is_first_render.current = false;
}, []);
// ...
You can extract it into a hook and simply import it in your component:
const useIsFirstRender = () => {
const is_first_render = useRef(true);
useEffect(() => {
is_first_render.current = false;
}, []);
return is_first_render.current;
};
Then in your component:
function ExampleTitleInput(props) {
const [title, setTitle] = useState(props.title || "");
const [lastCommittedTitle, setLastCommittedTitle] = useState(title);
const [updatesCount, setUpdatesCount] = useState(0);
const is_first_render = useIsFirstRender(); // Here
const debouncedTitle = useDebounce(title, 1000);
useEffect(() => {
setTitle(props.title || "");
}, [props.title]);
useEffect(() => {
// I don't want this to trigger when the value is passed by the props (i.e. - when initialized)
if (is_first_render) { // Here
return;
}
if (debouncedTitle !== lastCommittedTitle) {
setLastCommittedTitle(debouncedTitle);
setUpdatesCount(updatesCount + 1);
}
}, [debouncedTitle, lastCommittedTitle, updatesCount]);
// ...
You can change the useDebounce hook to be aware of the fact that the first set debounce value should be set immediately. useRef is perfect for that:
export function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
const firstDebounce = useRef(true);
useEffect(() => {
if (value && firstDebounce.current) {
setDebouncedValue(value);
firstDebounce.current = false;
return;
}
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(handler);
}, [value, delay]);
return debouncedValue;
}
I think you can improve your code in some ways:
First, do not copy props.title to a local state in ExampleTitleInput with useEffect, as it may cause excessive re-renders (the first for changing props, than for changing state as an side-effect). Use props.title directly and move the debounce / state management part to the parent component. You just need to pass an onChange callback as a prop (consider using useCallback).
To keep track of old state, the correct hook is useRef (API reference).
If you do not want it to trigger in the first render, you can use a custom hook, such as useUpdateEffect, from react-use: https://github.com/streamich/react-use/blob/master/src/useUpdateEffect.ts, that already implements the useRef related logic.