Paul
Paul

Reputation: 31

Rendered HTML not in sync with React component

I'm currently creating a custom React component in Meteor for adding images to a list (and later uploading them). However when I try to delete images from the list, always the last element is removed from the GUI. Initially I thought this was just a simple case of using the wrong index for deletion, but it turned out to be more than that.

This is what my ImageList component currently looks like:

import React from 'react';
import Dropzone from 'react-dropzone';
import cloneDeep from 'lodash.clonedeep';
import { ImageItem } from './image-item.js';

export class ImagesList extends React.Component {
  constructor(props) {
    super(props);

    this.values = this.props.images || [];

    this.onDrop = this.onDrop.bind(this);
    this.addImages = this.addImages.bind(this);
    this.deleteImage = this.deleteImage.bind(this);
    this.imageChanged = this.imageChanged.bind(this);
  }

  onDrop(files) {
    this.addImages(files);
  }

  onDropRejected() {
    alert('Invalid file type');
  }

  addImages(files) {
    files.forEach(file => {
      this.values.push({
        title: '',
        description: '',
        url: file.preview,
        file,
      });
    });

    this.forceUpdate();
  }

  deleteImage(index) {
    console.log('index to delete', index);
    console.log('images pre-delete', cloneDeep(this.values)); // deep-copy because logging is async
    this.values.splice(index, 1);
    console.log('images post-delete', cloneDeep(this.values)); // deep-copy because logging is async
    this.forceUpdate();
  }

  imageChanged(index, image) {
    this.values[index] = image;
    this.forceUpdate();
  }

  render() {
    console.log('--------RENDER--------');
    return (
      <div className="image-list">
        <div className="list-group">
          {this.values.length === 0 ?
            <div className="list-group-item">
              No images
            </div>
            :
            this.values.map((image, index) => {
              console.log('rendering image', image);
              return (
                <ImageItem
                  key={index}
                  image={image}
                  onDelete={() => { this.deleteImage(index); }}
                  onChange={(item) => { this.imageChanged(index, item); }}
                  deletable={true}
                />
              );
            })
          }
        </div>
        <Dropzone
          multiple={true}
          onDrop={this.onDrop}
          onDropRejected={this.onDropRejected}
          className="dropzone"
          activeClassName="dropzone-accept"
          rejectStyle={this.rejectStyle}
          accept={'image/*'}
        >
          <span>Drop files here</span>
        </Dropzone>
      </div>
    );
  }
}

The ImagesList component can be initialized with some values (for the sake of debugging), which it uses during rendering. For example:

<ImagesList images={[
  { title: 'Image 1', description: 'Image 1 description', url: 'http://cssdeck.com/uploads/media/items/3/3yiC6Yq.jpg' },
  { title: 'Image 2', description: 'Image 2 description', url: 'http://cssdeck.com/uploads/media/items/4/40Ly3VB.jpg' },
  { title: 'Image 3', description: 'Image 3 description', url: 'http://cssdeck.com/uploads/media/items/0/00kih8g.jpg' },
]}/>

ImagesList renders an ImageItem component for each image. This is what this component looks like:

import React from 'react';
import { RIEInput, RIETextArea } from 'riek';

export class ImageItem extends React.Component {
  constructor(props) {
    super(props);

    this.placeholder = {
      title: 'Title',
      description: 'Description',
    };

    this.value = this.props.image;
  }

  render() {
    return (
      <div className="list-group-item">
        <div className="text-content">
          <h4>
            <RIEInput
              className="description"
              value={this.value.title.length <= 0 ?
              this.placeholder.title : this.value.title}
              change={(item) => {
                this.value.title = item.value;
                this.props.onChange(this.value);
              }}
              validate={(value) => value.length >= 1}
              classEditing="form-control"
              propName="value"
            />
          </h4>
          <span>
            <RIETextArea
              className="description"
              value={this.value.description.length <= 0 ?
              this.placeholder.description : this.value.description}
              change={(item) => {
                this.value.description = item.value;
                this.props.onChange(this.value);
              }}
              validate={(value) => value.length >= 1}
              classEditing="form-control"
              propName="value"
              rows="2"
            />
          </span>
        </div>

        <img className="thumb img-responsive"
          style={{width: '20%' }}
          src={this.value.url}
          alt="Image"
          data-action="zoom"
        />

        {this.props.deletable ?
          <div className="delete-btn">
            <span onClick={this.props.onDelete}>
              &times;
            </span>
          </div>
          :
        undefined }
      </div>
    );
  }
}

Let's say I have three images, image A, B and C, and I want to delete image B. After pressing the delete button, image C will disappear from the GUI instead.

Inside the deleteImage() function of ImagesList, I am logging the index that is to be deleted and also log the values before and after the deletion. The index that is logged is correct, in this case that is index 1. Before the deletion the values are images A, B and C. After deletion the values are images A and C, as they should be.

I decided to do some logging inside the render() function of ImagesList as well. Unfortunately this also logs the correct values A and C, but A and B are actually rendered.

I have also tried to use the React state for this component instead of storing it in a local variable in conjunction with forceUpdate().

Another thing I have tried is to use the React Developer Tools plugin for Chrome. The Devtools also show the correct values, but the GUI still does not, as shown in this screenshot.

I'm currently out of ideas on what to try, any help would be appreciated! Using the snippets I provided, you should be able to create a Meteor project and reproduce this bug.

Upvotes: 0

Views: 533

Answers (1)

Paul
Paul

Reputation: 31

With MasterAM's suggestion I managed to find two different solutions.


A.) Using componentWillUpdate()

The this.value variable is set only once namely in the constructor of the ImageItem component. To ensure that changes are properly delegated, you have to update this.value inside the componentWillUpdate() function. Something like:

componentWillUpdate(nextProps, nextState) {
  this.value = nextProps.image;
}

B.) Using the property directly

This is definitely the more proper solution. Here we get rid of the local variable this.value inside the constructor of the ImageItem component.

Inside the render() function you replace this.value with this.props.image. Now without having to use the componentWillUpdate() function, everything works as expected.

Upvotes: 2

Related Questions