Tuesday, February 7, 2017

ReactJS access to Virtual DOM reconciliation result

Leave a Comment

I am updating a part of a page via a standard this.setState mechanism. I want to grab a hold of a elements that has been changed on a web page and provide a visual feedback to a user.

Let's say we have a component RichText that gets a data props. To render rich text it will delegate render to smaller components like Paragraph, Header, BulletPoints, Text, etc. The final result is a properly rendered rich text.

Later data props change (e.g. socket push). As a result of that Paragraphs can be added, or text changed, or things could move around. I want to provide a visual feedback to a user by simply highlighting HTML nodes that were changed.

In a nutshell I want to achieve what Chrome inspector is showing when you are looking at HTML tree. It blinks DOM changes.

ReactJS knows what was changed. Ideally I would like to get an access to that knowledge.

While smaller Components like Paragraph could be responsible for highlighting a difference within themselves, I don't think they have enough of a knowledge of the outside world to make it work as expected.

Format (simplified version)

{content: [{type: 'Document', content: [     {type: 'Paragraph', content: [              {type: 'Text', text: 'text text'},             {type: 'Reference', content: 'text text'},         ]},     {type: 'BulletPoints', content: [         {type: 'ListEntry', content: [         {type: 'Paragraph', content: [                  {type: 'Text', text: 'text text'},                 {type: 'Reference', content: 'text text'}, ]}]}]}] 

My current solution

I have a top level Component that knows how to render the entire Document by delegating job to other components. I have a life version HOC of it: LifeDocument that is responsible for a change visualization.

So I capture DOM before setState and after setState. Then I am using HtmlTreeWalker to spot a first difference (ignoring certain elements as I walk the tree).

11 Answers

Answers 1

React already have an addon for these situations. ReactCSSTransitionGroup

ReactCSSTransitionGroup is a high-level API based on ReactTransitionGroup and is an easy way to perform CSS transitions and animations when a React component enters or leaves the DOM. It's inspired by the excellent ng-animate library.

You can easily animate items that are entering or leaving a specific parent.

var ReactCSSTransitionGroup = React.addons.CSSTransitionGroup;    const nextId = (() => {    let lastId = 0;    return () => ++lastId;  })();    class TodoList extends React.Component {    constructor(props) {      super(props);      this.state = {items: [        {id: nextId(), text: 'hello'},         {id: nextId(), text: 'world'},         {id: nextId(), text: 'click'},         {id: nextId(), text: 'me'}      ]};      this.handleAdd = this.handleAdd.bind(this);    }      handleAdd() {      const newItems = this.state.items.concat([        {id: nextId(), text: prompt('Enter some text')}      ]);      this.setState({items: newItems});    }      handleRemove(toRemove) {      let newItems = this.state.items.filter(item => item.id !== toRemove.id);      this.setState({items: newItems});    }      render() {      const items = this.state.items.map((item) => (        <div key={item.id} onClick={() => this.handleRemove(item)}>          {item.text}        </div>      ));        return (        <div>          <button className="add-todo" onClick={this.handleAdd}>Add Item</button>                  <ReactCSSTransitionGroup            transitionName="example"            transitionEnterTimeout={500}            transitionLeaveTimeout={300}>            {items}          </ReactCSSTransitionGroup>        </div>      );    }  }    ReactDOM.render(<TodoList/>, document.getElementById("app"));
.example-enter {      background-color: #FFDCFF;    color: white;  }    .example-enter.example-enter-active {    background-color: #9E1E9E;      transition: background-color 0.5s ease;  }    .example-leave {    background-color: #FFDCFF;    color: white;  }    .example-leave.example-leave-active {    background-color: #9E1E9E;      transition: background-color 0.3s ease;  }    .add-todo {    margin-bottom: 10px;  }
<script src="https://unpkg.com/react@15/dist/react-with-addons.js"></script>  <script src="https://unpkg.com/react-dom@15/dist/react-dom.js"></script>    <div id="app"></div>

Answers 2

I think you should use componentDidUpdate

from the docs:

componentDidUpdate(prevProps, prevState) is invoked immediately after updating occurs. This method is not called for the initial render.

Use this as an opportunity to operate on the DOM when the component has been updated. This is also a good place to do network requests as long as you compare the current props to previous props (e.g. a network request may not be necessary if the props have not changed).

You could compare which component did change and then set a decorator style in the state, to use in your page.

Answers 3

You can write a HOC which wraps your leaf components within a PureComponent. This wrapper will then render the wrapped component with a special style when it detects a change through componentDidUpdate. It uses an internal flag to break infinite loop from a componentDidUpdate + setState situation.

Here's a sample code -

import React, {PureComponent} from "react";  let freshKid = (Wrapped, freshKidStyle) => {     return class FreshKid extends PureComponent{         state = {"freshKid" : true},         componentDidUpdate(){             if (this.freshKid){                 return;             }             this.freshKid = true;             setTimeout(()=>this.setState(                     {"freshKid" : false},                      ()=>this.freshKid = false                 ),                  100             );         }         render(){             let {freshKid} = this.state,             {style, ..rest} = this.props,             style = freshKid ? Object.assign({}, style, freshKidStyle) : style;              return <Wrapped style={style} {...rest} />;         }     } } 

You can use this to wrap a leaf component like so -

let WrappedParagraph = freshKid(Paragraph, {"color":"orangered"}); 

Or export all leaf components pre-wrapped.

Please note that the code is only an idea. Also, you should put some more checks in the timeout body to verify id the component has not been unmounted, before calling setState.

Answers 4

I think you should use shouldComponentUpdate, as far as I know, only here you can detect exactly your case.

Here my example:

class Text extends React.Component {     constructor(props) {         super(props);         this.state = {textVal: this.props.text, className: ''};     }     shouldComponentUpdate(nextProps, nextState) {         // Previous state equals to new state - so we have update nothing.         if (this.state.textVal === nextProps.text) {             this.state.className = '';             return false;         }         // Here we have new state, so it is exactly our case!!!         this.state.textVal = nextProps.text;         this.state.className = 'blink';         return true;     }     render() {         return (<i className={this.state.className}>{this.state.textVal}</i>);     } } 

It is only component Text (I left css and other components behind the scene), I think this code is most interesting, but you can try my working version on codepen, also here example with jquery and updations in loop.

Answers 5

Last edit

Ok now you finally included the data needed to understand it. You can handle it absolutely with componentDidMount, componentWillReceiveProps and componentDidUpdate, with some instance variables to keep some internal state unrelated to rendering in your "content" components.

Here you have a working snippet. I'm using some fake buttons to add new content to the end of the list and modify any of the items. This is a mock of your JSON messages coming in, but I hope you get the gist of it.

My styling is pretty basic but you could add some CSS transitions/keyframe animations to make the highlighting last only for a while instead of being fixed. That's however a CSS question not a React one. ;)

const Component = React.Component    class ContentItem extends Component {    constructor(props){      super(props)      this.handleClick = this.handleClick.bind(this)      //new by default      this._isNew = true      this._isUpdated = false    }    componentDidMount(){      this._isNew = false    }    componentDidUpdate(prevProps){          this._isUpdated = false         }    componentWillReceiveProps(nextProps){      if(nextProps.content !== this.props.content){        this._isUpdated = true      }    }    handleClick(e){      //hack to simulate a change in a specific content      this.props.onChange(this.props.index)    }    render(){      const { content, index } = this.props      const newStyle = this._isNew ? 'new' : ''      const updatedStyle = this._isUpdated ? 'updated': ''                 return (        <p className={ [newStyle, updatedStyle].join(' ') }>          { content }          <button style={{ float: 'right' }} onClick={ this.handleClick}>Change me</button>        </p>       )    }  }    class Document extends Component {    constructor(props){      super(props)      this.state = {        content: [          { type: 'p', content: 'Foo' },          { type: 'p', content: 'Bar' }        ]      }      this.addContent = this.addContent.bind(this)      this.changeItem = this.changeItem.bind(this)    }    addContent(){      //mock new content being added      const newContent = [ ...this.state.content, { type: 'p', content: `Foo (created at) ${new Date().toLocaleTimeString()}` }]      this.setState({ content: newContent })    }    changeItem(index){      //mock an item being updated      const newContent = this.state.content.map((item, i) => {        if(i === index){          return { ...item, content: item.content + ' Changed at ' + new Date().toLocaleTimeString() }        }        else return item      })      this.setState({ content: newContent })    }    render(){      return (        <div>          <h1>HEY YOU</h1>          <div className='doc'>            {              this.state.content.map((item, i) =>                 <ContentItem key={ i } index={ i } { ...item } onChange={ this.changeItem } />)            }          </div>          <button onClick={ this.addContent }>Add paragraph</button>        </div>      )        }  }    ReactDOM.render(<Document />, document.getElementById('app'));
.new {    background: #f00  }  .updated {    background: #ff0  }
<div id="app"></div>  <script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.1.0/react.min.js"></script>  <script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.1.0/react-dom.min.js"></script>

Answers 6

You can attach pass a callback as a ref to the node, and it will be invoked with the DOM node each time the DOM node is created/re-created.

You can use a common callback to track the created nodes.

Answers 7

Unfortunately, React doesn't provide a hook to listen state changes from outside component. You can use componentDidUpdate(prevProps, nextProps) to be notified of state changes of your component but you have to keep a reference of the previous generated DOM and compare it manually with the new DOM (using dom-compare, for example). I think that's you already do with your current solution.

I tried an alternative solution using MutationObserver and this technique to get the modified element position relative to document and display a bordered layer above the mutated element. It seems to work well, but I didn't check performances.

mutationObserver.js

const MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver;  const observer = new MutationObserver(function(mutations) {   mutations.forEach(function(mutation) {     if (mutation.addedNodes) {       mutation.addedNodes.forEach(showMutationLayer);     }   }); });  const showMutationLayer = (node) => {   const mutationLayer = document.createElement('div');   mutationLayer.style.position = 'absolute';   mutationLayer.style.border = '2px solid red';   document.body.appendChild(mutationLayer);   if (node.nodeType === Node.TEXT_NODE) {     node = node.parentNode;   }    if (node.nodeType !== Node.ELEMENT_NODE) {     return;   }   const { top, left, width, height } = getCoords(node);   mutationLayer.style.top = `${top}px`;   mutationLayer.style.left = `${left}px`;   mutationLayer.style.width = `${width}px`;   mutationLayer.style.height = `${height}px`;   setTimeout(() => {     document.body.removeChild(mutationLayer);   }, 500); };  function getCoords(elem) { // crossbrowser version     const box = elem.getBoundingClientRect();     const body = document.body;     const docEl = document.documentElement;     const scrollTop = window.pageYOffset || docEl.scrollTop || body.scrollTop;     const scrollLeft = window.pageXOffset || docEl.scrollLeft || body.scrollLeft;     const clientTop = docEl.clientTop || body.clientTop || 0;     const clientLeft = docEl.clientLeft || body.clientLeft || 0;     const top  = box.top +  scrollTop - clientTop;     const left = box.left + scrollLeft - clientLeft;     return {        top: Math.round(top),        left: Math.round(left),        width: box.width,       height: box.height     }; }  export default {    init(container) {      observer.observe(container, {        attributes: true,        childList: true,        characterData: true,        subtree: true      });    }  } 

main.js

import React from 'react'; import {render} from 'react-dom'; import App from './App.js'; import mutationObserver from './mutationObserver.js';  const appContainer = document.querySelector('#app');  // Observe mutations when you are in a special 'debug' mode // for example if (process.env.NODE_ENV === 'debug') {    mutationObserver.init(appContainer); }  render(<App />, appContainer); 

The advantages of this technique is you don't have to modify each of your components code to watch changes. You also don't modify the components generated DOM (the layer is outside the #app element). It is easy to enable/disable this functionality to preserve your application performance.

See it in action in this fiddle (you can edit the layer style, adding CSS transition for a nicer layer)

Answers 8

Before component is rendered you'll have to check if component's props changed. If they did, you'll have to add a class to the component and then remove that class after rendering. Adding css transition to that class will achieve you a blinking effect like in Chrome dev tools.

To detect changes in properties you should use componentWillReceiveProps(nextProps) component hook:

componentWillReceiveProps() is invoked before a mounted component receives new props. If you need to update the state in response to prop changes (for example, to reset it), you may compare this.props and nextProps and perform state transitions using this.setState() in this method.

This hook doesn't fire on component mount so additionally you will need to set the initial "highlighted" state in the constructor.

To remove the class after rendering you'll need to reset the state back to "not highlighted" in a setTimeout call so it happens outside the call stack and after the component will render.

In the example below type something in the input to see the paragraph highlighted:

class Paragraph extends React.Component {    constructor(props) {      super(props);      this.state = { highlighted: true };      this.resetHighlight();    }      componentWillReceiveProps(nextProps) {      if (nextProps.text !== this.props.text) {        this.setState({ highlighted: true });        this.resetHighlight();      }    }      resetHighlight() {      setTimeout(() => {        this.setState({ highlighted: false });      }, 0);    }      render() {      let classes = `paragraph${(this.state.highlighted) ? ' highlighted' : ''}`;      return (        <div className={classes}>{this.props.text}</div>      );      }  }  class App extends React.Component {    constructor(props) {      super(props);      this.state = { text: "type in me" };    }    handleInput(e) {      this.setState({ text: e.target.value });    }    render() {      return (        <div className="App">          <Paragraph text={this.state.text} />          <input type="text" onChange={this.handleInput.bind(this)} value={this.state.text} />        </div>      );    }  }    ReactDOM.render(    <App />,    document.getElementById('root')  );
.paragraph {    background-color: transparent;    transition: 1s;  }    .paragraph.highlighted {    background-color: red;    transition: 0s;  }
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.1.0/react.min.js"></script>  <script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.1.0/react-dom.min.js"></script>  <div id="root"></div>

Answers 9

I had faced same issue on a web app recently. My requirement was chrome like change notifier. The only difference that I need that feature globally. Since that feature required on UI (not important for server rendering) using a observer saved my life.

I set "notify-change" css class for the components and/or elements which I want to track. My observer listens the changes and checks if changed dom and/or its parents has "notify-change" class. If condition is matched then It simply adds "in" class for "notify-change" marked element to start fade effect. And removes "in" class in specific timeframe

(function () {      const observer = new MutationObserver(function (mutations) {          mutations.forEach(function (m) {              let parent = m.target && m.target.parentNode;              while (parent) {                  if (parent.classList && parent.classList.contains('notify-change')) {                      break;                  }                  parent = parent.parentNode;              }              if (!parent) return;              parent.classList.add('in');              setTimeout(function () {                  try {                      parent.classList.remove('in');                  } catch (err) {                  }              }, 300);          });      });      observer.observe(document.body, {subtree: true, characterData: true, characterDataOldValue: true});  })();    // testing    function test(){    Array.from(document.querySelectorAll(".notify-change"))    .forEach(e=>      e.innerHTML = ["lorem", "ipsum", "sit" , "amet"][Math.floor(Math.random() * 4)]    );  }    setInterval(test, 1000);  test();
.notify-change {    transition: background-color 300ms ease-out;    background-color:transparent;  }    .notify-change.in{    background-color: yellow !important;  }
<div>Lorem ipsum dolor sit amet, eu quod duis eius sit, duo commodo impetus an, vidisse cotidieque an pro. Usu dicat invidunt et. Qui eu <span class="notify-change">Ne</span> impetus electram. At enim sapientem ius, ubique labore copiosae sea eu, commodo persecuti instructior ad his. Mazim dicit iisque sit ea, vel te oblique delenit.    Quo at <span class="notify-change">Ne</span> saperet <span class="notify-change">Ne</span>, in mei fugit eruditi nonumes, errem clita volumus an sea. Elitr delicatissimi cu quo, et vivendum lobortis usu. An invenire voluptatum his, has <span class="notify-change">Ne</span> incorrupte ad. Sensibus maiestatis necessitatibus sit eu, tota veri sea eu. Mei inani ocurreret maluisset <span class="notify-change">Ne</span>, mea ex mentitum deleniti.    Quidam conclusionemque sed an. <span class="notify-change">Ne</span> omnes utinam salutatus ius, sea quem necessitatibus no, ad vis antiopam tractatos. Ius cetero gloriatur ex, id per nisl zril similique, est id iriure scripta. Ne quot assentior theophrastus eum, dicam soleat eu ius. <span class="notify-change">Ne</span> vix nullam fabellas apeirian, nec odio convenire ex, mea at hinc partem utamur. In cibo antiopam duo.    Stet <span class="notify-change">Ne</span> no mel. Id sea adipisci assueverit, <span class="notify-change">Ne</span> erant habemus sit ei, albucius consulatu quo id. Sit oporteat argumentum ea, eam pertinax constituto <span class="notify-change">Ne</span> cu, sed ad graecis posidonium. Eos in labores civibus, has ad wisi idque.    Sit dolore <span class="notify-change">Ne</span> ne, vis eu perpetua vituperata interpretaris. Per dicat efficiendi et, eius appetere ea ius. Lorem commune mea an, at est exerci senserit. Facer viderer vel in, etiam putent alienum vix ei. Eu vim congue putent constituto, ad sit agam <span class="notify-change">Ne</span> integre, his ei veritus tacimates.</div>

Answers 10

You could create a decorator function (or HOC if you prefer the term) that uses partial application to observe changes based on a provided observer function.

(Very) simple pen to demonstrate the concept: http://codepen.io/anon/pen/wgjJvO?editors=0110

The key parts of the pen:

// decorator/HOC that accepts a change observer function // and then a component to wrap function observeChanges(observer) {   return function changeObserverFactory(WrappedComponent) {     return class ChangeObserver extends React.Component {       constructor(props) {         super(props)         this.state = {           changed: false         }       }        componentWillReceiveProps(nextProps) {         if (typeof observer === 'function') {           observer(this.props, nextProps) && this.setState({ changed: true })         } else if (this.props !== nextProps) {           this.setState({ changed: true })         }       }        componentDidUpdate() {         if (this.state.changed) {           setTimeout(() => this.setState({ changed: false }), 300)         }       }        render() {         return <WrappedComponent {...this.props} changed={this.state.changed} />       }     }   } }  // a simple component for showing a paragraph const Paragraph = ({ changed, text }) => (   <p className={`${changed ? 'changed' : ''}`}>{text}</p> )  // a decorated change observer version of the paragraph, // with custom change observer function const ChangingParagraph = observeChanges(   (props, nextProps) => props.text !== nextProps.text )(Paragraph) 

This would allow each individual component to determine what constitutes a change for itself.

A few side notes:

  • you should avoid doing state updates in componentDidUpdate,
    componentWillUpdate, and shouldComponentUpdate.
    componentWillReceiveProps is the place for that.

    If you need to update state in response to a prop change, use componentWillReceiveProps()

  • Looking directly at the DOM to find the differences seems like a lot of unnecessary work when you already have your state as the source of truth and existing methods to compare current and next state built right into the component lifecycle.

Answers 11

I know this answer is out of the scope of your question, but it is well-intentioned another approach to your problem.

You are probably creating the app of the medium or large scale base on what you wrote, and as I can guess in that case, you should consider Flux or Redux architecture.

With this architecture in mind, your controller components can subscribe to the Application Store update and based on that you can update your presentational components.

If You Enjoyed This, Take 5 Seconds To Share It

0 comments:

Post a Comment