Unable to update state

Viewed 101

I am using MERN and Redux.

I have a clickHandler function that calls a findAuthor function which is imported from my actions. This finds a user by their id and returns it. I have added the user to the global state. I want to then retrieve the user and add their name to local state but i can't get this working. I keep getting this error TypeError: this.props.subAuthor is undefined. What am i missing here? When i try just printing to console i get no object showing until the second click. How do i get it t update straight away?

import React, { Component } from "react";
import PropTypes from "prop-types";
import GoogleSearch from "./GoogleSearch";
import { connect } from "react-redux";
import { fetchSubjects } from "../../actions/subject";
import { fetchComments } from "../../actions/comment";
import { updateSubject } from "../../actions/subject";
import { getUser } from "../../actions/authActions";

class Subject extends Component {
  // on loading the subjects and comments
  // are fetched from the database
  componentDidMount() {
    this.props.fetchSubjects();
    this.props.fetchComments();
  }

  constructor(props) {
    super(props);
    this.state = {
      // set inital state for subjects
      // description, summary and comments all invisible
      viewDesription: -1,
      viewSummary: -1,
      comments: [],
      name: "",
    };
  }

  componentWillReceiveProps(nextProps) {
    // new subject and comments are added to the top
    // of the arrays
    if (nextProps.newPost) {
      this.props.subjects.unshift(nextProps.newPost);
    }
    if (nextProps.newPost) {
      this.props.comments.unshift(nextProps.newPost);
    }
  }

  clickHandler = (id) => {
    // when a subject title is clicked pass in its id
    // and make the description and comments visible
    const { viewDescription } = this.state;
    this.setState({ viewDescription: viewDescription === id ? -1 : id });
    // add relevant comments to the state
    var i;
    var temp = [];
    for (i = 0; i < this.props.comments.length; i++) {
      if (this.props.comments[i].subject === id) {
        temp.unshift(this.props.comments[i]);
      }
    }
    this.setState({
      comments: temp,
    });
    // save the subject id to local storage
    // this is done incase a new comment is added
    // then the subject associated  with it can be retrieved
    // and added as a property of that comment
    localStorage.setItem("passedSubject", id);
    //testing getUser
    this.findAuthor(id); // this updates the tempUser in state
    this.setState({ name: this.props.subAuthor.name });
  };

  // hovering on and off subjects toggles the visibility of the summary
  hoverHandler = (id) => {
    this.setState({ viewSummary: id });
  };
  hoverOffHandler = () => {
    this.setState({ viewSummary: -1 });
  };

  rateHandler = (id, rate) => {
    const subject = this.props.subjects.find((subject) => subject._id === id);
    // when no subject was found, the updateSubject won't be called
    subject &&
      this.props.updateSubject(id, rate, subject.noOfVotes, subject.rating);
    alert("Thank you for rating this subject.");
  };

  // take in the id of the subject
  // find it in the props
  // get its author id
  // call the getUser passing the author id
  findAuthor(id) {
    console.log("Hitting findAuthor function");
    const subject = this.props.subjects.find((subject) => subject._id === id);
    const authorId = subject.author;
    console.log(authorId);
    this.props.getUser(authorId);
  }

  render() {
    const subjectItems = this.props.subjects.map((subject) => {
      // if the state equals the id set to visible if not set to invisible
      var view = this.state.viewDescription === subject._id ? "" : "none";
      var hover = this.state.viewSummary === subject._id ? "" : "none";
      var comments = this.state.comments;
      var subjectAuthor = this.state.name;
      return (
        <div key={subject._id}>
          <div className="subjectTitle">
            <p
              className="title"
              onClick={() => this.clickHandler(subject._id)}
              onMouseEnter={() => this.hoverHandler(subject._id)}
              onMouseLeave={() => this.hoverOffHandler()}
            >
              {subject.title}
            </p>
            <p className="rate">
              Rate this subject:
              <button onClick={() => this.rateHandler(subject._id, 1)}>
                1
              </button>
              <button onClick={() => this.rateHandler(subject._id, 2)}>
                2
              </button>
              <button onClick={() => this.rateHandler(subject._id, 3)}>
                3
              </button>
              <button onClick={() => this.rateHandler(subject._id, 4)}>
                4
              </button>
              <button onClick={() => this.rateHandler(subject._id, 5)}>
                5
              </button>
            </p>
            <p className="rating">
              Rating: {(subject.rating / subject.noOfVotes).toFixed(1)}/5
            </p>
            <p className="summary" style={{ display: hover }}>
              {subject.summary}
            </p>
          </div>

          <div className="subjectBody " style={{ display: view }}>
            <div className="subjectAuthor">
              <p className="author">
                Subject created by: {subjectAuthor}
                <br /> {subject.date}
              </p>
            </div>

            <div className="subjectDescription">
              <p className="description">{subject.description}</p>
            </div>

            <div className="subjectLinks">Links:</div>

            <div className="subjectComments">
              <p style={{ fontWeight: "bold" }}>Comments:</p>
              {comments.map((comment, i) => {
                return (
                  <div key={i} className="singleComment">
                    <p>
                      {comment.title}
                      <br />
                      {comment.comment}
                      <br />
                      Comment by : {comment.author}
                    </p>
                  </div>
                );
              })}
              <a href="/addcomment">
                <div className="buttonAddComment">ADD COMMENT</div>
              </a>
            </div>
          </div>
        </div>
      );
    });

    return (
      <div id="Subject">
        <GoogleSearch />

        {subjectItems}
      </div>
    );
  }
}

Subject.propTypes = {
  fetchSubjects: PropTypes.func.isRequired,
  fetchComments: PropTypes.func.isRequired,
  updateSubject: PropTypes.func.isRequired,
  getUser: PropTypes.func.isRequired,
  subjects: PropTypes.array.isRequired,
  comments: PropTypes.array.isRequired,
  newPost: PropTypes.object,
  subAuthor: PropTypes.object,
};

const mapStateToProps = (state) => ({
  subjects: state.subjects.items,
  newSubject: state.subjects.item,
  comments: state.comments.items,
  newComment: state.comments.item,
  subAuthor: state.auth.tempUser[0],
});

// export default Subject;
export default connect(mapStateToProps, {
  fetchSubjects,
  fetchComments,
  updateSubject, // rate subject
  getUser, // used for getting author name
})(Subject, Comment);
1 Answers

I'd like to offer an alternative solution to the current code you have been writing so far. I know this is not codereview (and it wouldn't be on topic there, unless it is actually working code), but still, I would like to show you a different way of dividing up your components.

From what I see, you have many components, currently all jampacked in to one very large component. This can complicate things on the long run, and if you can, you should avoid it.

As I see it from the code you have posted, you really have several components, which I divided in:

  • Subject
  • Comment
  • User
  • Rating
  • RatingViewer

By dividing your now large component, you are making it easier to handle the data for one component at a later time and reuse the components you are making. You might want to reuse some of these components.

For the purpose of an alternative solution, I created a very quick and basic demo on how you might refactor your code. This is only a suggestion, in the hope that it will also solve your current problem.

The problem you are having is that you want to load that data, and use it directly. Any fetch operation is however asynchronous, so after you have called this.props.getUser(authorId); your author gets added somewhere in your state, but it will not be available until fetching has been completed and your component gets re-rendered.

I hope the information in the demo can give you some insight, it might not be exactly matching your scenario, but it should give you an indication of what you could do differently.

// imports
const { Component } = React;
const { Provider, connect } = ReactRedux;
const { render } = ReactDOM;
const { createStore, combineReducers } = Redux;

// some fake db data
const db = {
  comments: [
    { id: 1, subject: 2, user: 2, comment: 'Interesting book' },
    { id: 2, subject: 2, user: 3, comment: 'Is interesting the only word you know, you twit' }
  ],
  subjects: [
    { 
      id: 1, 
      title: 'Some interesting title', 
      summary: 'Some interesting summary / plot point', 
      author: 2, 
      rate: 0, 
      noOfVotes: 0 
    },
    { 
      id: 2, 
      title: 'Some less interesting title', 
      summary: 'Some more interesting summary / plot point', 
      author: 1, 
      rate: 5, 
      noOfVotes: 2
    }
  ],
  authors: [
    { id: 1, name: 'John Doe' },
    { id: 2, name: 'Jane Doe' }
  ],
  users: [
    { id: 1, name: 'user 1' },
    { id: 2, name: 'user 2' },
    { id: 3, name: 'user 3' }
  ]
};

// reducers
const authorReducer = ( state = {}, action ) => {
  switch (action.type) {
    case 'author/add':
      return { ...state, [action.payload.id]: action.payload };
    default:
      return state;
  }
};

const userReducer = ( state = {}, action ) => {
  switch (action.type) {
    case 'user/add':
      return { ...state, [action.payload.id]: action.payload };
    default:
      return state;
  }
};

const subjectReducer = ( state = {}, action ) => {
  switch (action.type) {
    case 'subject/retrieved':
      return Object.assign( {}, ...action.payload.map( subject => ({ [subject.id]: subject }) ) );
    case 'subject/add':
      return { ...state, [action.payload.id]: action.payload };
    case 'subject/update':
      const { id } = action.payload;
      return { ...state, [id]: action.payload };
    default:
      return state;
  }
};

const commentReducer = ( state = [], action ) => {
  switch (action.type) {
    case 'comment/retrieved':
      return action.payload.slice();
    case 'comments/add':
      return [...state, action.payload ];
    default:
      return state;
  }
};

// create the store
const store = createStore( combineReducers({
  users: userReducer,
  authors: authorReducer,
  comments: commentReducer,
  subjects: subjectReducer
}) );

// some promise aware fetch methods
const fakeFetch = (entity, filter = null) => {
  const entities = db[entity];
  return Promise.resolve( (filter ? entities.filter( filter ) : entities).map( e => ({...e}) ) );
}

const fakeUpdate = (entity, id, updatedValue ) => {
  const targetEntity = db[entity].find( e => e.id === id );
  if (!targetEntity) {
    return Promise.reject();
  }
  Object.assign( targetEntity, updatedValue );
  return Promise.resolve( { ...targetEntity } );
}

// separate components
class App extends Component {
  render() {
    return <Subjects />;
  }
}

// subjects component
// cares about retrieving the subjects and displaying them
class SubjectsComponent extends Component {
  componentDidMount() {
    this.props.fetchSubjects();
  }
  render() {
    const { subjects } = this.props;
    if (!subjects || !subjects.length) {
      return <div>Loading</div>;
    }
    return (
      <div>
      { subjects.map( subject => <Subject key={subject.id} subject={subject} /> ) }
      </div>
    );
  }
}

// subject component
// displays a subject and fetches the comments for "all" subjects
// this should probably only fetch its own comments, but then reducer has to be changed aswell
// to be aware of that
class SubjectComponent extends Component {
  componentDidMount() {
    this.props.fetchComments();
  }
  render() {
    const { subject } = this.props;
    return (
      <div className="subject">
        <h1>{ subject.title }<RateView subject={subject} /></h1>
        <p>{ subject.summary }</p>
        <Rate subject={subject} />
        <h2>Comments</h2>
        { this.props.comments && this.props.comments.map( comment => <Comment key={comment.id} comment={comment} /> ) }
      </div>
    );
  }
}

// Just displays a comment and a User component
const Comment = ({ comment }) => {
  return (
    <div className="comment">
      <p>{ comment.comment }</p>
      <User id={comment.user} />
    </div>
  );
}

// User component
// fetches the user in case he hasn't been loaded yet
class UserComponent extends Component {
  componentDidMount() {
    if (!this.props.user) {
      this.props.fetchUser( this.props.id );
    }
  }
  render() {
    return <span className="user">{ this.props.user && this.props.user.name }</span>;
  }
}

// shows the current rating of a post
const RateView = ({ subject }) => {
  if (subject.noOfVotes === 0) {
    return <span className="rating">No rating yet</span>;
  }
  const { rate, noOfVotes } = subject;
  return <span className="rating">Total rating { (rate / noOfVotes).toFixed(1) }</span>;
}

// enables voting on a subject, can be triggered once per rendering
// this should truly be combined with the user who rated the subject, but it's a demo
class RateComponent extends Component {
  constructor() {
    super();
    this.onRateClicked = this.onRateClicked.bind( this );
    this.state = {
      hasRated: false,
      rateValue: -1
    };
  }
  onRateClicked( e ) {
    const userRate = parseInt( e.target.getAttribute('data-value') );
    const { subject } = this.props;
    this.setState({ hasRated: true, rateValue: userRate }, () => {
      this.props.updateRate( { ...subject, rate: subject.rate + userRate, noOfVotes: subject.noOfVotes + 1 } );
    });
  }
  render() {
    if (this.state.hasRated) {
      return <span className="user-rate">You rated this subject with { this.state.rateValue }</span>;
    }
    return (
      <div>
      { [1, 2, 3, 4, 5].map( value => <button type="button" onClick={ this.onRateClicked } data-value={value} key={value}>{ value }</button> ) }
      </div>
    );
  }
}

// connecting all the components to the store, with their states and dispatchers
const Subjects = connect( 
  state => ({ subjects: Object.values( state.subjects ) }),
  dispatch => ({
    fetchSubjects() {
      return fakeFetch('subjects').then( result => dispatch({ type: 'subject/retrieved', payload: result }) );
    }
  }))( SubjectsComponent );

// ownProps will be used to filter only the data required for the component that it is using
const Subject = connect(
  (state, ownProps) => ({ comments: state.comments.filter( comment => comment.subject === ownProps.subject.id ) }),
  dispatch => ({
    fetchComments() {
      return fakeFetch('comments' ).then( result => dispatch({ type: 'comment/retrieved', payload: result }) );
    }
  }))( SubjectComponent );
 
const User = connect(
  (state, ownProps) => ({ user: state.users[ownProps.id] }),
  dispatch => ({
    fetchUser( id ) {
      return fakeFetch('users', user => user.id === id).then( result => dispatch({ type: 'user/add', payload: result[0] }) );
    }
  }))( UserComponent );
  
const Rate = connect( null, dispatch => ({ 
  updateRate( updatedSubject ) {
    return fakeUpdate('subjects', updatedSubject.id, updatedSubject).then( updated => dispatch({ type: 'subject/update', payload: updated }) );
  }
  }))( RateComponent );

// bind it all together and run the app
const targetElement = document.querySelector('#container');
render( <Provider store={store}><App /></Provider>, targetElement );
.user {
  font-style: italic;
  font-size: .9em;
}
.comment {
  padding-left: 10px;
  background-color: #efefef;
  border-top: solid #ddd 1px;
}
h1, h2 {
  font-size: .8em;
  line-height: .9em;
}
.rating {
  padding: 5px;
  display: inline-block;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.13.1/umd/react.production.min.js" integrity="sha512-SUJujhtUWZUlwsABaZNnTFRlvCu7XGBZBL1VF33qRvvgNk3pBS9E353kcag4JAv05/nsB9sanSXFbdHAUW9+lg==" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.13.1/umd/react-dom.production.min.js" integrity="sha512-SYsXmAblZhruCNUVmTp5/v2a1Fnoia06iJh3+L9B9wUaqpRVjcNBQsqAglQG9b5+IaVCfLDH5+vW923JL5epZA==" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-redux/7.2.1/react-redux.min.js" integrity="sha512-Ae6lzX7eAwqencnyfCtoAf2h3tQhsV5DrHiqExqyjKrxvTgPHwwOlM694naWdO2ChMmBk3by5oM2c3soVPbI5g==" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/redux/4.0.5/redux.min.js" integrity="sha512-P36ourTueX/PrXrD4Auc1kVLoTE7bkWrIrkaM0IG2X3Fd90LFgTRogpZzNBssay0XOXhrIgudf4wFeftdsPDiQ==" crossorigin="anonymous"></script>
<div id="container"></div>

Related