React - Splitting large comment component into multiple components - reactjs

I have a large Comment component which works great but is fairly lengthy. I have recently added a report button to the UI which toggles a reported state which should then change the output of the comment (removing everything and displaying a reported message). Trying to write the if statement in the return method of the component made me realise that I should be splitting this component up (not to mention that I have found myself copy/pasting a lot of code between this Comment component and the very similar Reply component).
The comment has 3 main 'views' - the default view, the reported view and the 'my comment' view.
Whenever I have tried to split up components in the past I have found myself getting bogged down in the passing of multiple props to each component. I'm not sure whether I'm doing it wrong or whether it is just something I need to get used to. Any tips on the best way of splitting this component up would be appreciated.
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { replyToCommentService, deleteCommentService, reportCommentService } from '../../../services/CommentService';
import { likeService, removeLikeService } from '../../../services/LikeService';
import Reply from './Reply';
import Avatar from '../Avatars/Avatar';
import IconWithText from '../Icons/IconWithText';
import CommentForm from './CommentForm';
import Dropdown from '../Dropdowns/Dropdown';
import DropdownSection from '../Dropdowns/DropdownSection';
export default class Comment extends Component {
constructor(props) {
super(props);
this.state = {
replies: this.props.replies,
showReply: false,
reply: '',
replyBtnDisabled: true,
liked: this.props.liked,
numberOfLikes: this.props.likes.length,
moreActionsActive: false,
reported: this.props.reported,
};
}
handleInput = (reply) => {
this.setState({ reply }, () => {
this.fieldComplete();
});
}
fieldComplete = () => {
if (this.state.reply.length) {
this.setState({ replyBtnDisabled: false });
} else {
this.setState({ replyBtnDisabled: true });
}
}
toggleReply = () => {
this.setState({ showReply: !this.state.showReply }, () => {
if (this.state.showReply === true) {
this.replyInput.focus();
}
});
}
postReply = () => {
const data = { comment_id: this.props.id, comment_content: this.state.reply };
replyToCommentService(data, this.postReplySuccess, this.error);
}
postReplySuccess = (res) => {
this.setState({ replies: this.state.replies.concat(res.data) });
this.toggleReply();
this.handleInput('');
}
error = (res) => {
console.log(res);
}
toggleLike = (e) => {
e.preventDefault();
const data = { model_id: this.props.id, model_type: 'comment' };
if (this.state.liked) {
removeLikeService(this.props.id, 'comment', this.removeLikeSuccess, this.error);
} else {
likeService(data, this.likeSuccess, this.error);
}
}
likeSuccess = () => {
this.toggleLikeState();
this.setState({ numberOfLikes: this.state.numberOfLikes += 1 });
}
removeLikeSuccess = () => {
this.toggleLikeState();
this.setState({ numberOfLikes: this.state.numberOfLikes -= 1 });
}
toggleLikeState = () => {
this.setState({ liked: !this.state.liked });
}
moreActionsClick = () => {
this.setState({ moreActionsActive: !this.state.moreActionsActive });
}
deleteReply = (replyId) => {
this.setState({ deletedReplyId: replyId });
deleteCommentService(replyId, this.deleteReplySuccess, this.error);
}
deleteReplySuccess = () => {
this.setState(prevState => ({ replies: prevState.replies.filter(reply => reply.id !== this.state.deletedReplyId) }));
}
ifEnterPressed = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.postReply();
}
}
reportComment = () => {
const data = { model_id: this.props.id, model_type: 'comment' };
reportCommentService(data, this.reportCommentSuccess, this.error);
}
reportCommentSuccess = (res) => {
console.log(res);
}
render() {
let repliesList;
if (this.state.replies.length) {
repliesList = (this.state.replies.map((reply) => {
const { id, owner_id, content, owner_image_url, owner_full_name, ago, likes, liked } = reply;
return (
<Reply
key={id}
id={id}
authorId={owner_id}
title={content}
image={owner_image_url}
authorName={owner_full_name}
timeSinceComment={ago}
likes={likes}
liked={liked}
newComment={this.newCommentId}
deleteReply={this.deleteReply}
/>
);
}));
}
const commentClass = classNames('comment-container', {
'my-comment': this.props.myComment,
'comment-reported': this.state.reported,
});
let likeBtnText;
const numberOfLikes = this.state.numberOfLikes;
if (numberOfLikes > 0) {
likeBtnText = `${numberOfLikes} Likes`;
if (numberOfLikes === 1) {
likeBtnText = `${numberOfLikes} Like`;
}
} else {
likeBtnText = 'Like';
}
const likeBtnClass = classNames('like-btn', 'faux-btn', 'grey-link', 'h5', {
liked: this.state.liked,
});
let likeIconFill;
if (this.state.liked) {
likeIconFill = 'green';
} else {
likeIconFill = 'grey';
}
return (
<li className={commentClass}>
<div className="comment">
<Avatar image={this.props.image} />
<div className="body">
<div className="header">
{this.props.authorName}
<span className="h5 text-grey">{this.props.timeSinceComment}</span>
<Dropdown
size="S"
position="right"
onClick={this.moreActionsClick}
active={this.state.moreActionsActive}
handleClickOutside={this.moreActionsClick}
disableOnClickOutside={!this.state.moreActionsActive}
>
<DropdownSection>
{this.props.myComment &&
<button className="faux-btn dropdown-link" onClick={() => this.props.deleteComment(this.props.id)}>Delete comment</button>
}
</DropdownSection>
<DropdownSection>
<button className="faux-btn dropdown-link" onClick={() => this.reportComment(this.props.id)}>Report as inappropriate</button>
</DropdownSection>
</Dropdown>
</div>
<div className="comment-text"><p>{this.props.title}</p></div>
<div className="actions">
<button onClick={this.toggleLike} className={likeBtnClass}>
<IconWithText text={likeBtnText} iconName="thumb-up" iconSize="S" iconFill={likeIconFill} />
</button>
<button onClick={this.toggleReply} className="reply-btn faux-btn grey-link h5">
<IconWithText text="Reply" iconName="reply" iconSize="S" iconFill="grey" />
</button>
</div>
</div>
</div>
{this.state.replies.length > 0 &&
<div className="replies-container">
<ul className="replies-list faux-list no-margin-list">
{repliesList}
</ul>
</div>
}
{this.state.showReply &&
<div className="reply-to-comment-form">
<CommentForm
commentContent={this.handleInput}
postComment={(e) => { e.preventDefault(); this.postReply(); }}
formDisabled={this.state.replyBtnDisabled}
placeholder="Write a reply... press enter to submit"
btnText="Reply"
inputRef={(input) => { this.replyInput = input; }}
handleKeyPress={this.ifEnterPressed}
/>
</div>
}
</li>
);
}
}
Comment.propTypes = {
id: PropTypes.number,
authorId: PropTypes.number,
title: PropTypes.string,
image: PropTypes.string,
authorName: PropTypes.string,
timeSinceComment: PropTypes.string,
likes: PropTypes.array,
liked: PropTypes.bool,
replies: PropTypes.array,
myComment: PropTypes.bool,
deleteComment: PropTypes.func,
newCommentId: PropTypes.number,
reported: PropTypes.bool,
};

Well the general problem is where your state lives.
Currently you have your state in the component (and/or services), that makes splitting up the component somewhat tricky and not feeling so "natural".
The reason is that each natural sub component (for example a replies list, or a reply itself) requires pieces of that state and perhaps also needs to modify that state, that's then being done by the "property passing" and it can be tedious. Passing pieces of the state down to sub components and/or passing event callbacks down such as "upDateState" "showThis" "showThat".
This is sometimes what you want, you can then create a stateless components that only renders ui, a list of answers for example. If this is what you want then yes, you just have to get used to passing in props from parents.
The other answer to continue growing you application is modeling it by its state and the only way to do that (properly) is to abstract the application state away from the component. Creating a state that does not live inside of a component, a state that every component can access.
You might have guess what my suggestion is by now, have a look at Redux (or similar state management lib.) and you can easily cut out pieces (components) and attach them to the Redux global state and action. Once you get used to "never" keep application state in your components you wont go back. :)
PS!
This is perhaps not an answer but its to long for a comment. Just wanted to share my thoughts.

Related

Toggle only one element of map array with react and typescript

i'm mapping a reviews API's array and i want to show only the clicked review when i click on "read more" but at the moment is expanding all the reviews of my array, i'm using typescript and it's all new to me so i don't know how to move, how should i pass the information of the index to my state?
interface State {
reviews: Review[];
isReadMore: boolean;
}
export default class MoviePage extends Component<{}, State> {
state: State = {
reviews: [],
isReadMore: false,
};
componentDidMount() {
this.asyncAwaitFunc();
this.toggle(arguments);
}
asyncAwaitFunc = async () => {
try {
const reviewmovie = await axios.get<ReviewResponse>(
`https://api.themoviedb.org/3/movie/${this.props.match.params.id}/reviews?api_key=`
);
this.setState({
reviews: reviewmovie.data.results,
});
} catch (error) {}
};
toggle(index: any) {
this.setState({
isReadMore: !this.state.isReadMore,
});
render() {
const { isReadMore, reviews } = this.state;
return (
<>
<ReviewGrid>
{reviews.map((review, index) => (
<ReviewContent key={index}>
{this.state.isReadMore
? review.content.substring(0, 650)
: review.content}
<Button onClick={() => this.toggle(index)}>
{isReadMore ? "...read more" : " show less"}
</Button>
</ReviewContent>
))}
</ReviewGrid>
</>
);
}
}
I think that the problem is that you save isReadMore once but you need to save isReadMore for each review.
Here is an example:
interface ReviewRow {
review: Review
isReadMore: boolean
}
interface State {
reviews: ReviewRow[]
}
export default class MoviePage extends Component<{}, State> {
state: State = {
reviews: []
}
componentDidMount() {
this.asyncAwaitFunc()
}
asyncAwaitFunc = async () => {
try {
const reviewMovies = await axios.get<ReviewResponse>(
`https://api.themoviedb.org/3/movie/${this.props.match.params.id}/reviews?api_key=`
)
this.setState({
reviews: reviewMovies.data.results.map((review) => {
return { review: review, isReadMore: false }
})
})
} catch (error) {
console.log(error)
}
}
toggle(index: number) {
const { reviews } = this.state
reviews[index].isReadMore = !reviews[index].isReadMore
this.setState({ reviews })
}
render() {
const { reviews } = this.state
return (
<>
<ReviewGrid>
{reviews.map((reviewRow, index) => {
;<ReviewContent key={index}>
{ reviewRow.isReadMore ? reviewRow.review.content.substring(0, 650) : reviewRow.review..content}
<Button onClick={() => this.toggle(index)}>{reviewRow.isReadMore ? '...read more' : ' show less'}</Button>
</ReviewContent>
})}
</ReviewGrid>
</>
)
}
}
I've modified your code a bit to make this work since I don't have access to the API or the various interfaces and components you're using, but this should give you an idea. You're tracking isReadMore as a single piece of state, which is why it's toggling for every item in the array. You need to track state individually for each review. This is one of several solutions that could work, but the idea here is to take the API's response, and map it to a new set of objects which includes a new key that you'll add, isReadMore, then you can toggle this property individually for each review.
Here is the working example on CodeSandbox.
EDIT: Here is a link to a second example which does not require you to map over the results of the api call to add a new key to track isReadMore state. This approach tracks the state separately in a Map<Review, boolean> instead. An ES6 map works well here because you can use the review object itself as the key and the boolean value can track your hide/show state.
Original Solution
interface Review {
title: string;
content: string;
}
interface MyReview extends Review {
isReadMore: boolean;
}
interface State {
reviews: MyReview[];
}
export default class MoviePage extends React.Component<{}, State> {
state: State = {
reviews: []
};
componentDidMount() {
this.asyncAwaitFunc();
}
asyncAwaitFunc = () => {
try {
const reviewsFromApi: Review[] = [
{
title: "Some Movie",
content: "some movie review that's pretty long"
},
{
title: "Some Other Movie",
content: "an even longer super long movie review that's super long"
}
];
this.setState({
reviews: reviewsFromApi.map((r) => ({ ...r, isReadMore: false }))
});
} catch (error) {
console.log(error);
}
};
toggle(index: number) {
this.setState({
reviews: this.state.reviews.map((r, i) => {
if (i === index) {
return {
...r,
isReadMore: !r.isReadMore
};
}
return r;
})
});
}
render() {
const { reviews } = this.state;
return (
<>
<div>
{reviews.map((review, index) => (
<div key={index}>
<p>
{review.isReadMore
? review.content.substring(0, 10) + "..."
: review.content}
</p>
<button onClick={() => this.toggle(index)}>
{review.isReadMore ? "Read more" : " Show less"}
</button>
</div>
))}
</div>
</>
);
}
}

React state not updating accordingly

I have a search component where the search result updates according to the search input, where if there is data returned from the API, it is rendered as a book grid, if there is no data, a message is displayed, and if the search input is empty, nothing is rendered.
My problem is that when query state updates the searchResult state does update but when I delete the search input so fast (make the search input empty), query becomes updates as an empty string but searchResult does not update according to it. What could be the problem?
Here is the code to the search component: (Note: I tried the componentDidUpdate() method and the setState() callback function but nothing worked)
import React, { Component } from "react";
// import "React Router" components
import { Link } from "react-router-dom";
// import custom components
import Book from "./Book";
// import required API
import * as BooksAPI from "../BooksAPI";
export default class BookSearch extends Component {
state = {
query: "",
searchResult: [],
};
handleInputChange = (query) => {
this.setState(
{
query: query.trim(),
},
() => {
if (query) {
console.log(query);
BooksAPI.search(query).then((books) => {
this.setState({ searchResult: books });
});
} else {
this.setState({ searchResult: [] });
}
}
);
};
// componentDidUpdate(currentProps, currentState) {
// if (currentState.query !== this.state.query && this.state.query) {
// BooksAPI.search(this.state.query).then((books) => {
// this.setState({ searchResult: books });
// });
// } else if (currentState.query !== this.state.query && !this.state.query) {
// this.setState({ searchResult: [] });
// }
// }
render() {
const { query, searchResult } = this.state;
const { updateBookShelves } = this.props;
return (
<div className="search-books">
<div className="search-books-bar">
<Link to="/" className="close-search">
Close
</Link>
<div className="search-books-input-wrapper">
<input
type="text"
placeholder="Search by title or author"
value={query}
onChange={(event) => this.handleInputChange(event.target.value)}
/>
</div>
</div>
<div className="search-books-results">
<ol className="books-grid">
{ searchResult.error ?
<p>No results matching your search</p>
: searchResult.map((book) => (
<Book
key={book.id}
book={book}
updateBookShelves={updateBookShelves}
/>
))
)
) )}
</ol>
</div>
</div>
);
}
}
I am not 100% sure about this solution since it is using setState inside callback of other setState but you can give it a try.
I think you can probably need to use setTimeout before calling api for data and before checking if query exist or not we can set timeout to null so it will not call unwanted api calls.
handleInputChange = query => {
this.setState(
{
query: query.trim()
},
() => {
if (this.timeout) {
clearTimeout(this.timeout);
this.timeout = null;
}
if (query) {
this.timeout = setTimeout(() => {
BooksAPI.search(query).then(books => {
this.setState({ searchResult: books });
});
}, 800);
} else {
this.setState({ searchResult: [] });
}
}
);
};

React Component code organization best practise

Is it ok to have all the functions inside of the App.js in React and then passing them to the other components through context:
export default class App extends Component {
constructor(props) {
super(props);
this.state = {
listName: '',
existingListNames: [],
item: {
id: '',
toDo: ''
},
listItems: []
};
this.onItemChange = this.onItemChange.bind(this);
this.onItemAdd = this.onItemAdd.bind(this);
}
componentDidMount() {
axios.get('http://localhost:8080/v1/names')
.then(response => {
const listItems = response.data;
listItems.forEach(item => {
this.setState({
existingListNames: [...this.state.existingListNames, item.name]
})
})
})
}
onItemChange(event) {
this.setState({
item: {
id: uuid(),
[event.target.name]: event.target.value
}
});
}
onItemAdd(event) {
event.preventDefault();
const { item } = this.state;
this.setState({
listItems: [...this.state.listItems, item],
item: {
id: '',
toDo: ''
}
});
document.getElementById('formItem').reset();
}
render() {
const currentItem = this.onItemChange;
const addItem = this.onItemAdd;
const { listItems, existingListNames, listName } = this.state;
return (
<div className="main-container">
<Context.Provider
value={{
currentItem,
addItem,
removeItem,
listItems,
existingListNames,
listName
}}
>
<InputContainer />
</Context.Provider>
</div>
);
}
}
And then access them from the component:
import React, { useContext } from "react";
import Context from "../../../context/Context";
export default function ItemInput() {
const { currentItem, addItem } = useContext(Context);
return (
<form className="item" id="formItem">
<input
onChange={currentItem}
type="text"
className="newInput"
name="toDo"
placeholder="New Task"
autoComplete="off"
/>
<button onClick={addItem} className="checkButton">
<i className="fas fa-check fa-sm"></i>
</button>
</form>
);
}
Or is it better to separate them into different files and import straigth into components instead. What if App.js gets really big with lots of functions?
I'm going to assume you're looking for the "best practice" answer, since that's what you put in your title. That answer depends on your use case:
Propagating handlers to their respective children, should be done at the nearest parent to the child component. The only reason to use context would be if the parent-child relationship is not applicable to your Component hierarchy; i.e. one component to a random-component-nowhere-near-the-other.
Beyond one of those 2 scenarios, there's a lot of debate around the React community on using Context or Props for direct descendent children. To prepare yourself well for the eventual debate, feel free to read up on the pros & cons of each . If you do opt for context tho, there's some definite disadvantages.

How do I manage my array of children components' states?

I'm new to react, so forgive me. I'm having a problem understanding states, specifically those of children.
Purpose: I'm trying to create a form that a user can append more and more components -- in this case, images.
What happens: User appends 2 or more images. User tries to upload an image with UploadButton component, but both the images are the same. I believe this has to do with both appended children sharing the same state.
Question: How do I give each appended child its own image without affecting the other appended children?
class Page extends Component
constructor (props) {
super(props);
this.state = {
id: '',
numChildren: 0,
images: [],
}
this.onAddChild = this.onAddChild.bind(this);
}
showModal() {
this.setState({
numChildren: 0,
images: [],
});
}
renderModal()
const children = [];
//Here's my array of child components
for(var i = 0; i < this.state.numChildren; i += 1) {
children.push(<this.ChildComponent key={i} />);
}
return (
<ReactModal>
<this.ParentComponent addChild={this.onAddChild}>
{children}
</this.ParentComponent>
</ReactModal>
)
}
onAddChild = () => {
this.setState({
numChildren: this.state.numChildren + 1
})
}
ParentComponent = (props) => (
<div>
{props.children}
<Button onClick={props.addChild}>Add Item</Button>
</div>
);
ChildComponent = () => (
<div>
<UploadButton
storage="menus"
value={this.state.images}
onUploadComplete={uri => this.setState({images: uri})}
/>
</div>
);
}
Here's the code for UploadButton:
import React, { Component } from 'react';
import uuid from 'uuid';
import firebase from '../config/firebase';
class UploadButton extends Component {
constructor(props) {
super(props);
this.state = {
isUploading: false
}
}
handleClick() {
const input = document.createElement("INPUT");
input.setAttribute("type", "file");
input.setAttribute("accept", "image/gif, image/jpeg, image/png");
input.addEventListener("change", ({target: {files: [file]}}) => this.uploadFile(file));
input.click();
}
uploadFile(file) {
console.log('F', file);
const id = uuid.v4();
this.setState({ isUploading: true })
const metadata = {
contentType: file.type
};
firebase.storage()
.ref('friends')
.child(id)
.put(file, metadata)
.then(({ downloadURL }) => {
this.setState({ isUploading: false })
console.log('Uploaded', downloadURL);
this.props.onUploadComplete(downloadURL);
})
.catch(e => this.setState({ isUploading: false }));
}
render() {
const {
props: {
value,
style = {},
className = "image-upload-button",
},
state: {
isUploading
}
} = this;
return (
<div
onClick={() => this.handleClick()}
className={className}
style={{
...style,
backgroundImage: `url("${this.props.value}")`,
}}>
{isUploading ? "UPLOADING..." : !value ? 'No image' : ''}
</div>
);
}
}
export default UploadButton;
I tried to exclude all unnecessary code not pertaining to my problem, but please, let me know if I need to show more.
EDIT: This is my attempt, it doesn't work:
//altered my children array to include a new prop
renderModal() {
const children = [];
for (var i = 0; i < this.state.numChildren; i += 1) {
children.push(<this.ChildComponent imageSelect={this.onImageSelect} key={i} />);
}
//...
};
//my attempt to assign value and pass selected image back to images array
ChildComponent = () => (
<div>
<UploadButton
storage="menus"
value={uri => this.props.onImageSelect(uri)} //my greenness is really apparent here
onUploadComplete={uri => this.setState({images: uri})}
/>
//...
</div>
);
//added this function to the class
onImageSelect(uri) {
var el = this.state.images.concat(uri);
this.setState({
images: el
})
}
I know I'm not accessing the child prop correctly. This is the most complexity I've dealt with so far. Thanks for your time.
When you write this.state in Child / Parent component, you are actually accessing the state of Page. Now, I would recommend that you pass in the index of the child to the Child like so
children.push(<this.ChildComponent key={i} index={i}/>)
so that each children deals with only its own image like so
ChildComponent = ({index}) => (
<div>
<UploadButton
storage="menus"
value={this.state.images[index]}
onUploadComplete={uri => {
let images = this.state.images.slice()
images[index] = uri
this.setState({images})
}}
/>
</div>
);

Reactjs setState not updating for this one function only

For this application, clicking a listed item once should create a button component underneath this listed item. Clicking the button should cause this listed item to be deleted.
I am currently facing difficulty trying to 'delete' the listed item after the button is clicked. Here is the code that went wrong (this is found in CountdownApp component) :
handleDelete(index) {
console.log('in handleDelete')
console.log(index)
let countdownList = this.state.countdowns.slice()
countdownList.splice(index, 1)
console.log(countdownList) // countdownList array is correct
this.setState({
countdowns: countdownList
}, function() {
console.log('after setState')
console.log(this.state.countdowns) // this.state.countdowns does not match countdownList
console.log(countdownList) // countdownList array is still correct
})
}
In the code above, I removed the item to be deleted from countdownList array with splice and tried to re-render the app with setState. However, the new state countdowns do not reflect this change. In fact, it returns the unedited state.
I have also tried the following:
handleDelete(index) {
this.setState({
countdowns: [] // just using an empty array to check if setState still works
}, function() {
console.log('after setState')
console.log(this.state.countdowns)
})
}
In the code above, I tried setting state to be an empty array. The console log for this.state.countdowns did not print out an empty array. It printed out the unedited state again
This is the only event handler that isn't working and I have no idea why (main question of this post) :/
If I have 'setstate' wrongly, why does the other 'setState' in other parts of my code work?? (I would like to request an in-depth explanation)
This is all my code for this app (its a small app) below:
import React from 'react'
import ReactDOM from 'react-dom'
class DeleteButton extends React.Component {
render() {
return (
<ul>
<button onClick={this.props.onDelete}>
delete
</button>
</ul>
)
}
}
class Countdown extends React.Component {
render () {
//console.log(this.props)
return (
<li
onClick={this.props.onClick}
onDoubleClick={this.props.onDoubleClick}
>
{this.props.title} - {this.props.days}, {this.props.color}
{this.props.showDeleteButton ? <DeleteButton onDelete={this.props.onDelete}/> : null }
</li>
)
}
}
const calculateOffset = date => {
let countdown = new Date(date)
let today = new Date
let timeDiff = countdown.getTime() - today.getTime()
let diffDays = Math.ceil(timeDiff / (1000 * 3600 * 24))
return diffDays
}
class CountdownList extends React.Component {
countdowns() {
let props = this.props
// let onClick = this.props.onClick
// let onDoubleClick = this.props.onDoubleClick
let rows = []
this.props.countdowns.forEach(function(countdown, index) {
rows.push(
<Countdown
key={index}
title={countdown.title}
days={calculateOffset(countdown.date)}
color={countdown.color}
showDeleteButton={countdown.showDeleteButton}
onDelete={() => props.onDelete(index)}
onClick={() => props.onClick(index)}
onDoubleClick={() => props.onDoubleClick(index)}
/>
)
})
return rows
}
render() {
return (
<div>
<ul>
{this.countdowns()}
</ul>
</div>
)
}
}
class InputField extends React.Component {
render() {
return (
<input
type='text'
placeholder={this.props.placeholder}
value={this.props.input}
onChange={this.props.handleInput}
/>
)
}
}
class DatePicker extends React.Component {
render() {
return (
<input
type='date'
value={this.props.date}
onChange={this.props.handleDateInput}
/>
)
}
}
class CountdownForm extends React.Component {
constructor(props) {
super(props)
this.state = {
title: this.props.title || '',
date: this.props.date || '',
color: this.props.color || ''
}
}
componentWillReceiveProps(nextProps) {
this.setState({
title: nextProps.title || '',
date: nextProps.date || '',
color: nextProps.color || ''
})
}
handleSubmit(e) {
e.preventDefault()
this.props.onSubmit(this.state, this.reset())
}
reset() {
this.setState({
title: '',
date: '',
color: ''
})
}
handleTitleInput(e) {
this.setState({
title: e.target.value
})
}
handleDateInput(e) {
this.setState({
date: e.target.value
})
}
handleColorInput(e) {
this.setState({
color: e.target.value
})
}
render() {
return (
<form
onSubmit={(e) => this.handleSubmit(e)}
>
<h3>Countdown </h3>
<InputField
placeholder='title'
input={this.state.title}
handleInput={(e) => this.handleTitleInput(e)}
/>
<DatePicker
date={this.state.date}
handleDateInput={(e) => this.handleDateInput(e)}
/>
<InputField
placeholder='color'
input={this.state.color}
handleInput={(e) => this.handleColorInput(e)}
/>
<button type='submit'>Submit</button>
</form>
)
}
}
class CountdownApp extends React.Component {
constructor() {
super()
this.state = {
countdowns: [
{title: 'My Birthday', date: '2017-07-25', color: '#cddc39', showDeleteButton: false},
{title: 'Driving Practice', date: '2017-07-29', color: '#8bc34a', showDeleteButton: false},
{title: 'Korean BBQ', date: '2017-08-15', color: '#8bc34a', showDeleteButton: false}
]
}
}
handleCountdownForm(data) {
if (this.state.editId) {
const index = this.state.editId
let countdowns = this.state.countdowns.slice()
countdowns[index] = data
this.setState({
title: '',
date: '',
color: '',
editId: null,
countdowns
})
} else {
data.showDeleteButton = false
const history = this.state.countdowns.slice()
this.setState({
countdowns: history.concat(data),
})
}
}
handleDelete(index) {
console.log('in handleDelete')
console.log(index)
let countdownList = this.state.countdowns.slice()
countdownList.splice(index, 1)
console.log(countdownList)
this.setState({
countdowns: countdownList
}, function() {
console.log('after setState')
console.log(this.state.countdowns)
})
}
handleCountdown(index) {
const countdownList = this.state.countdowns.slice()
let countdown = countdownList[index]
countdown.showDeleteButton = !countdown.showDeleteButton
this.setState({
countdowns: countdownList
})
}
handleDblClick(index) {
const countdownList = this.state.countdowns
const countdown = countdownList[index]
this.setState({
title: countdown.title,
date: countdown.date,
color: countdown.color,
editId: index
})
}
render() {
return (
<div>
<CountdownForm
title={this.state.title}
date={this.state.date}
color={this.state.color}
onSubmit={(data) => {this.handleCountdownForm(data)}}
/>
<CountdownList
countdowns={this.state.countdowns}
onDelete={(index) => this.handleDelete(index)}
onClick={(index) => this.handleCountdown(index)}
onDoubleClick={(index) => this.handleDblClick(index)}
/>
</div>
)
}
}
ReactDOM.render(
<CountdownApp />,
document.getElementById('app')
)
I managed to find the answer to my own question!
setState worked as expected. The bug was due to <li> container that wrapped the event handler.
Clicking <li> causes it to call onClick event (which is managed by handleCountdown function in CountdownApp component) which causes it to setState.
As the delete button was wrapped in <li> container, clicking the delete button calls 2 event listeners - handleCountdown and handleDelete. handleCountdown is called twice in this case, once from clicking <li> to expand and the next call when the delete button is clicked.
There is a high chance that the last async setState dispatched from handleCountdown overwrites handleDelete's setState. Hence, the bug.
Here is changes: (I recoded everything again so the names might differ a little but the logic stays the same)
class Countdown extends React.Component {
render () {
return (
<li>
<div onClick={this.props.onClick} > // Add this div wrapper!
{this.props.title} - {this.props.days}, {this.props.color}
</div>
{this.props.toShow ?
<ButtonsGroup
onDelete={this.props.onDelete}
onEdit={this.props.onEdit}
/>
: null}
</li>
)
}
}
So the solution is to separate the clickable area and the buttons. I added a div wrapper over the text in <li> so whenever the text in <li> is clicked, the added <ul> will be out of onClick event handler area.

Resources