I'm new to React/using API json data in a project so I'm having a little trouble. I've created a function where a user can type in a search query and a list of devices associated with their query will show up. These device names are fetched from an API. I'm trying to make it so that when the plus sign next to a device is clicked, it adds this device to a new array that is then displayed to the screen.
I'm not 100% familiar with the concept of state in React and I think that's where my issue is (in the addDevice function). It's partially working, where I click the device and it displays at the bottom, but when I click another device, instead of adding to the list, it just replaces the first device.
class App extends React.Component {
state = {
search: "",
devices: [],
bag: []
};
addDevice = (e, data) => {
console.log(data);
const newData = [this.state.devices.title];
this.setState({
bag: newData.concat(data)
});
};
onChange = e => {
const { value } = e.target;
this.setState({
search: value
});
this.search(value);
};
search = search => {
const url = `https://www.ifixit.com/api/2.0/suggest/${search}?doctypes=device`;
fetch(url)
.then(results => results.json())
.then(data => {
this.setState({ devices: data.results });
});
};
componentDidMount() {
this.search("");
}
render() {
return (
<div>
<form>
<input
type="text"
placeholder="Search for devices..."
onChange={this.onChange}
/>
{this.state.devices.map(device => (
<ul key={device.title}>
<p>
{device.title}{" "}
<i
className="fas fa-plus"
style={{ cursor: "pointer", color: "green" }}
onClick={e => this.addDevice(e, device.title)}
/>
</p>
</ul>
))}
</form>
<p>{this.state.bag}</p>
</div>
);
}
}
I want it to display all the devices I click one after another, but right now each device clicked just replaces the previous one clicked
I think you're close. It appears that you are getting the devices array and the bag array mixed up.
I'd suggest using Array.from to create a copy of your state array. Then push the new item into the array. Concat is used to merged two arrays.
addDevice = (e, data) => {
// create new copy of array from state
const newArray = Array.from(this.state.bag);
// push new item into array
newArray.push(data);
// update the state with the new array
this.setState({
bag: newArray
});
}
Then if you want to show the device titles as a comma separated string, you could just do:
<p>{this.state.bag.join(', ')}</p>
Hope this helps.
The issue is with your addDevice method and specifically with how you create newData. You set newData to [this.state.devices.title], which evaluates to [undefined] since this.state.devices is an array and therefore has no attribute called title. Therefore, the updated value of state.bag will be [undefined, data], and only render as data which is the title of the most recently clicked device.
I think what you mean to do here is append the title of the clicked device to the array state.bag. You can do this with an addDevice method like this:
addDevice = (e, data) => {
console.log(data);
const newBag = this.state.bag.concat(data);
this.setState({
bag: newBag
});
};
Though a better practice way of updating state.bag would make use of the functional form of setState, and the spread operator (...) is more common for this sort of stuff than using concat. Also renaming data to something more explanatory (like deviceTitle) would be helpful here. Example:
addDevice = (e, deviceTitle) => {
this.setState(prevState => ({
bag: [...prevState.bag, deviceTitle],
});
}
Edit:
If you want to add functionality to remove devices from state.bag, you can create a method called removeDevice and add a button next to each bag item when rendering.
For example:
removeDevice = (e, deviceTitle) => {
this.setState(prevState => ({
bag: prevState.bag.filter(d => d !== deviceTitle),
});
}
Then in your render method you would have something like this:
<ul>
{this.state.bag.map(deviceTitle => (
<li>
<span>{ deviceTitle }</span>
<button onClick={ e => this.removeDevice(e, deviceTitle) }>remove</button>
</li>
))}
</ul>
Related
Suppose I have long list (let's assume there is no pagination yet) where each list item has input and ability to update own value (as a part of collection). Let's say code looks something like that:
const initItems = [
{ id: 0, label: "Hello world" },
...
{ id: 100, label: "Goodby" }
];
function List() {
const [items, setItems] = React.useState([...initItems]);
const handleChange = React.useCallback((e, id) => {
setItems(items.map(item => {
if (item.id === id) {
return {
...item,
label: e.target.value
}
}
return item;
}));
}, [items]);
return (
<ul>
{items.map(({ id, label }) => {
return (
<Item
id={id}
key={id}
label={label}
onChange={handleChange}
/>
)
})}
</ul>
)
}
// Where Item component is:
const Item = React.memo(({ onChange, label, id }) => {
console.log('Item render');
return (
<li>
<input type="text" value={label} onChange={e => onChange(e, id)} />
</li>
)
});
Looks pretty straightforward, right? While wrapping Item component with React.memo() what I wanted to achieve is to avoid re-render of each Item when some of the Item's gets updated. Well, I'm not sure it should works with this strategy, since each Item is a part of collection (items) and when I update any Item then items gets mapped and updated. What I did try - is to write custom areEqual method for Item component, where I do comparison of label value from props:
function areEqual(prev, next) {
return prev.label === next.label;
}
however with this approach the behaviour of updating items breaks down completely and updating next item reset previous updates and so on (I even could not observe any pattern to explain).
So the question: is it possible to avoid re-rendering of every item in such collection while having ability to update value of individual item?
Your problem here that you change callback on each render. So, you change callback, it changes onChange and this, in turn, runs rerender. To avoid it you can use updater function with setState.
const handleChange = React.useCallback((e, id) => {
// I made separate function so it would be easier to read
// You can just write `(items) =>` before your `items.map` and it will work
function updater(items) {
// we have freshest items here
return items.map((item) => {
if (item.id === id) {
return {
...item,
label: e.target.value,
};
}
return item;
});
}
// pass function
setItems(upadter);
// removed items from dependencies
}, []);
This way, your updater function will always get current value of state into parameters, and your props will update for actually updated item. Another solution would be to write custom updater that compares all values, but onChange. This is ok in short term, but this can become complex and cumbersome to maintain.
Here is live example: https://codesandbox.io/s/unruffled-johnson-ubz1l
I have 2 components, OneBook component has an option to "add to collection", i have an onClick event that sets the state of "collect" to true (default value is false of course). In my second component "Collection", i have a map function that returns a list of book elements (with title, author,etc) IF its collect state is true. Yet when i click on the "add to collection" which sets the state to true successfully (because i console logged it and the state is being updated), the map function isn't showing the elements even though they have the state of collect set as true.
Here is my code for clarity.
Or you can see the code sandbox to edit the code directly: https://codesandbox.io/s/stupefied-lewin-06f6m?file=/src/OneBook.js
OneBook.js
const [collect, setCollect] = useState(true)
return (
<small onClick={() => {setCollect(true);console.log(collect)}} className="addcollect"><i className="fas fa-bookmark bookmark"></i>Add To Collection</small>
)
Collection.js
{
books.map(book => {
return (book.collect? <li><img src={book.img} alt="img"/>{book.title} <span>{book.author}</span></li> : "")
})
}
You were not updating the books value in context properly and also collect value has no impact on the orignal books array in context. Have a look at the codesandbox below.
Let me know if it helps
Codesandbox link
Note:
As you asked for the explanation of this:
return { ...elem, collect: true };
I am using the spread operator here, which essentially keep all the value of books object and just update the collect field.
Let's see one example below:
A = {
a: 1,
b: 2
}
B = {...A, b: 3}
Now B will be having something like this structure:
{
a: 1,
b: 3
}
As i see you dont update the actual book object in the context when you click on add to collection.
change your onClick fn to this
const setCollect = (value) => {
//loop though all the books and change the collect property of the book after the add to collection click
const showedBook = books.map(b => {
if (b.isbn !== props.isbn) {
return b;
}
return {
...b,
collect: value,
};
});
setBooks(
[...showedBook]
);
};
);
};
<small onClick={() => setCollect(true)}....
updated demo
It is because the collect state has no relations to book object. So you need to create a function that updates the books.
const addToCollection = () => {
books.map((book) => {
if (book.isbn === props.isbn) {
console.log(book.isbn)
book.collect = true;
}
});
return;
};
Then call it on click event on your Add to collection button.
<small
onClick={() => {
addToCollection();
}}
className="addcollect"
>
<i className="fas fa-bookmark bookmark"></i>Add To Collection
</small>
I have a list of input to generate dynamically from an array of data I retrieve from an API.
I use .map() on the array to generate each of my input, and set value={this.state.items[i]} and the onChange property (with a modified handleChange to handle change on an array properly).
Now, I set in my constructor this.state = { items: [{}] }, but since I don't know how many items are going to be generate, value={this.state.items[i].value} crash since this.state.items[n] doesn't exist.
The solution is then to set each this.state.items[i] = {} (using Array.push for example) first, and then generate all the inputs.
var apiData = [{ value: "" }, { value: "" }]
this.setState({
items: apiData,
inputs: apiData.map((v, i) => {
return <input key={i} value={this.state.items[i].value}
onChange={(e) => this.handleChangeArray(e, i)} />
})
})
https://jsfiddle.net/qzb17dut/38/
The issue with this approach is that this.state.items doesn't exist yet on value={this.state.items[i].value} and we get the error Cannot read property 'value' of undefined.
Thankfully, setState() comes with a handy second argument that allows to do something only once the state is set. So I tried this:
var apiData = [{ value: "" }, { value: "" }]
this.setState({
items: apiData,
}, () => this.setState({
inputs: apiData.map((v, i) => {
return <input key={i} value={this.state.items[i].value}
onChange={(e) => this.handleChangeArray(e, i)} />
})
}))
https://jsfiddle.net/qzb17dut/39/
(Update: Please have a look at this example that better illustrate the use case: https://jsfiddle.net/jw81uo4y/1/)
Looks like everything should work now right? Well, for some reason, I am having this very weird bug were value= doesn't update anymore like when you forget to set onChange= on an input, but here onChange= is still called, value= is just not updated making the field remaining not editable.
You can see on the jsfiddle the problem for each method. The first one doesn't have the state set yet, which would allow the input to be edited, but crash because the state value was not yet set. And the second method fix the first issue but introduce this new weird bug.
Any idea about what I am doing wrong? Am I hitting the limit of react here? And do you have a better architecture for this use case? Thanks!
What about this approach instead, where you set the state of the API values only and then, generate the input based on the state from the render via Array.prototype.map like so
constructor (props) {
this.state = {items: []}
}
async componentDidMount(){
const apiData = await fetchApiData()
this.setState({items: apiData})
}
handleChange = (value, index) => {
const items = this.state.items;
items[index].value = value;
this.setState({ items });
};
updateState = () => {
const items = this.state.items;
items.push({value: ''}); // default entry on our item
this.setState({ items });
};
// here ur state items is exactly same structure as ur apiData
onSubmit =()=> {
console.log('this is apiData now', this.state.items)
}
render () {
<button onClick={this.updateState}>update state with inputs</button>
<button onClick={this.onSubmit}>Submit</button>
{this.state.items.map((item, index) => (
<input
key={index}
value={item.value}
onChange={e => this.handleChange(e.target.value, index)}
/>
))}
}
here is the codesandbox code for it
https://codesandbox.io/s/icy-forest-t942o?fontsize=14
with this, it will generate the input based on the items on the state, which in turns have the click handler which updates the state.
Well if I understand correctly, apiData is assigned to state.items and then also used to generate the inputs array. That means that for your purpose apiData and state.items are equivalent. Why don't you use the third map argument like:
var apiData = [{ value: "" }, { value: "" }]
this.setState({
items: apiData,
inputs: apiData.map((v, i, arr) => {
return <input key={i} value={arr[i].value}
onChange={(e) => this.handleChangeArray(e, i)} />
})
});
or the apiData array directly?
I've created a filter function in React. The user would type a search term into the input and the list would filter correctly. However, when the user erased the last letter the list would not update back to the previous results. To remedy this I had to setState twice, once to render the original array of items, and once to render filtered items:
componentDidMount() {
fetch("https://jsonplaceholder.typicode.com/users")
.then(response => response.json())
.then(users => {
this.setState({ filteredUsers: users });
this.setState({ users });
});
}
The filter function looks like this and is updated via onChange from the input:
filterList = event => {
let updateUsers = this.state.users;
updateUsers = updateUsers.filter(user => {
return (
user.name.toLowerCase().search(event.target.value.toLowerCase()) !== -1
);
});
this.setState({ filteredUsers: updateUsers });
};
I then render the component with filteredUsers:
render() {
const { filteredUsers } = this.state;
return (
<div className="App">
<input type="text" placeholder="Search" onChange={this.filterList} />
<div className="list">
<List users={filteredUsers} />
</div>
</div>
);
}
The question is whether I can streamline the process a bit without having to set State twice with the same data. Following DRY principles I find it unnecessary to do this, but fail to see how else I could make the filtering work.
Also, I plan on implementing several more filters to further limit search results, so the answer should have scalability in mind.
Check the components on codesandbox
You can render users if there aren't filteredUsers yet. So you have one setState on componentDidMount and you render the list like this <List users={filteredUsers || users} />, with the initial state being { users: [] }.
See working sandbox: https://codesandbox.io/s/xlp9lv53vw
i have an issue trying to create a "typehead" funcitonality in my app, i have an "input" that listen to onChange, and that onChange is calling to a Redux reducer that search for a tag in hole store, i need to retrive all matches with my search, over here everything is ok, but when i delete my search, my hole store is equals to my filtered results, and i want that when my search is empty it returns hole my store. (gif and code)
case 'SEARCH_BY_TAG':
let tag = action.tag
let filtered = state.slice(0)
if(tag != ""){
const copied = state.filter(item => {
return item.tags.find(obj => {
if(obj.name.indexOf(tag) > -1){
return true
}
})
})
return filtered = copied.filter(Boolean)
}else{
return filtered
}
break;
Instead of filtering things out inside your reducer, do it directly on render(), there is nothing wrong with that.
You can still use the SEARCH_BY_TAG action to keep track of the search keyword and use it to apply the filter when rendering your list.
I think you should refactor your state to change it from this:
[ item1, item2, item3, ... ]
to this:
{
query: '',
options: [ item1, item2, item3, ... ]
}
This way you can do what #Raspo said in his answer - do the filtering of the options in your render function.
Currently when you change the text in the search field, you are dispatching this action:
{
type: 'SEARCH_BY_TAG',
tag: 'new query'
}
I think you should change the action name, and the reducer code, to look more like this:
// note this is almost exactly the same as the old action
{
type: 'CHANGE_AUTOCOMPLETE_QUERY',
query: 'new query'
}
and the reducer could then change to this:
case CHANGE_AUTOCOMPLETE_QUERY:
return Object.assign({}, state, {
query: action.query
})
Note that in the reducer case I just wrote, the options part of the state isn't changed at all. It remains constant.
Now let's assume your current render function looks something like this:
const options = reduxState // not sure exactly how you get the state but whatever
return (
<div>
{options.map(option => {
<Option option={option} />
})}
</div>
)
This code relies on getting the state as an array. You could change it in the new setup to do this:
const query = reduxState.query
const options = reduxState.options.filter(item => {
return item.tags.find(obj => {
if(obj.name.indexOf(query) > -1){
return true
}
})
})
return (
<div>
{options.map(option => {
<Option option={option} />
})}
</div>
)