deadcode
deadcode

Reputation: 2316

Good approach to managing nested state in React

EDIT: I rewrote this question to clarify what I'm after - thank you to the people who have responded so far helping me hone it.

I am trying to understand how best to manage complex, nested state in React, while also limiting the number of times render() is called for components whose content has not changed.

As background:

Suppose I have state with both "authors" and "publications" in an object like this:

{
  'authors' : {
    234 : {
      'name' : 'Alice Ames',
      'bio' : 'Alice is the author of over ...',
      'profile_pic' : 'http://....'
    },
    794 : {
      'name' : 'Bob Blake',
      'bio' : 'Hailing from parts unknown, Bob...',
      'profile_pic' : 'http://....'
    },
    ...more authors...
 },
 'publications' : {
    539 : {
      'title' : 'Short Story Vol. 2',
      'author_ids' : [ 234, 999, 220 ]
    },
    93  : {
      'title' : 'Mastering Fly Fishing',
      'author_ids' : [ 234 ]
    },
    ...more publications...
  }
}

In this contrived example the state has two main areas, accessed by the authors and publications keys.

The authors key leads to an object keyed on an ID of the author, which leads to an object with some author data.

The publications key leads to an object keyed on the ID of the publication that has some publication data, and an array of authors.

Suppose my state is in an App component with child components like the following pseudo JSX:

...
<App>
  <AuthorList authors={this.state.authors} />
  <PublicationList authors={this.state.authors} publications={this.state.publications} />
</App>
...

...
class AuthorList extends React.Component {
  render() {
    let authors = this.props.authors;
    return (
      <div>
        { Object.keys( authors ).map( ( author_id ) => {
          return  <Author author={authors[author_id]} />;
        }
      </div>
    );
  }
}
...

...
class PublicationList extends React.Component {
  render() {
    let publications = this.props.publications;
    let authors = this.props.authors;
    return (
      <div>
        { Object.keys( publications ).map( ( publication_id ) => {
          return  <Publication publication={publications[publication_id]} authors=authors />;
        }
      </div>
    );
  }
}
...

Assume AuthorList has a bunch of child Author components, and PublicationList has a bunch of child Publication components that render the actual content of those things.

Here is my question: Suppose I want to update the bio for a given author, but I don't want render() to be called for all the Author and Publication objects whose content have not changed.

From this answer:

ReactJS - Does render get called any time "setState" is called?

A React component's render() function will get called any time its state, or the state of any of its parents, change - regardless of whether that state change has anything to do with the props of that component. This behavior can be changed with shouldComponentUpdate.

How people handle complex state like the above - it doesn't seem like calling render() on large numbers of components on every state change is a good solution (even if the resulting rendered object is the same and so no change occur to the actual DOM).

Upvotes: 14

Views: 8724

Answers (6)

cwtuan
cwtuan

Reputation: 1861

According to react state management libraries list for 2023, you could use Redux.

In Redux, all of your nest state could be stored in a single global "store".

Upvotes: 0

FurkanO
FurkanO

Reputation: 7308

Here is a way to accomplish this efficiently and in a readable way using Object Spread Syntax.

let state = {
    authors : {
        ...this.state.authors, 
        [ givenId ] : { 
            ...this.state.authors[ givenID ], 
            bio : newValue 
        }
    }  
}
this.setState(state)

Please remember that you have to pass a 'key' as a prop when you map items in jsx.

This is mainly because, reconciliation ( React's "diffing" algorithm to check what has changed ) thing that react does checks keys for mapped jsx ( roughly naming it jsx ).

Anyway, managing state in reacts state/setState or in redux is irrelevant to 'reconciliation'.

In both cases, you could change the part of a nested data using 'Object Spread Syntax' syntax.

All you would care about the rest is to pass 'identical' keys to the mapped jsx. So that, although react rerenders, it does not try to make dom updates to unnecessary parts, which is expensive.

Upvotes: 7

deadcode
deadcode

Reputation: 2316

Thank you to jpdeatorre and daveols for pointing me at Redux.

Here is an example application (with tons of corner cutting, but it shows the technique) of using Redux to isolate components from state changes that are irrelevant to them.

In this example the changes to the author Alice with id 1 don't cause Author components that don't depend on Alice to have their render() called.

This is because Redux's supplied shouldComponentUpdate for its connected react components evaluates whether the props and if relevant state have changed.

Be forewarned that Redux's optimization here is shallow. To determine wither or not to skip render() Redux's shouldComponentUpdate checks if:

  • The old and new props are === to one another
  • Or, if not that they have the same keys and the values of those keys are === to one another.

So it could result in render() being called for components whose values are still logically equivalent however the props to those components and their first level keys do not compare as equal with ===. See: https://github.com/reactjs/react-redux/blob/master/src/utils/shallowEqual.js

Note also that in order to prevent calling render() on the "dumb" component of Author I had to connect() it to Redux to enable Redux's shouldComponentUpdate logic - even though that component is doing nothing at all with the state and just reads its props.

import ReactDOM from 'react-dom';
import React from 'react';

import { Provider, connect } from 'react-redux';
import { createStore, combineReducers } from 'redux';

import update from 'immutability-helper';


const updateAuthor = ( author ) => {
  return ( {
    type : 'UPDATE_AUTHOR',
    // Presently we always update alice and not a particular author, so this is ignored.
    author
  } );
};

const updateUnused = () => {
  return ( {
    type : 'UPDATE_UNUSUED',
    date : Date()
  } );
};

const initialState = {
  'authors': {
    1: {
      'name': 'Alice Author',
      'bio': 'Alice initial bio.'
    },
    2: {
      'name': 'Bob Baker',
      'bio': 'Bob initial bio.'
    }
  },
  'publications': {
    1 : {
      'title' : 'Two Authors',
      'authors' : [ 1, 2 ]
    },
    2 : {
      'title' : 'One Author',
      'authors' : [ 1 ]
    }
  }
};

const initialDate = Date();

const reduceUnused = ( state=initialDate, action ) => {
  switch ( action.type ) {
    case 'UPDATE_UNUSED':
      return action.date;

    default:
      return state;
  }
};

const reduceAuthors = ( state=initialState, action ) => {
  switch ( action.type ) {
    case 'UPDATE_AUTHOR':
      let new_bio = state.authors['1'].bio + ' updated ';
      let new_state = update( state, { 'authors' : { '1' : { 'bio' : {$set : new_bio } } } } );
      /*
      let new_state = {
        ...state,
        authors : {
          ...state.authors,
          [ 1 ] : {
            ...state.authors[1],
            bio : new_bio
          }
        }
      };
      */
      return new_state;

    default:
      return state;
  }
};

const testReducers = combineReducers( {
  reduceAuthors,
  reduceUnused
} );

const mapStateToPropsAL = ( state ) => {
  return ( {
    authors : state.reduceAuthors.authors
  } );
};

class AuthorList extends React.Component {

  render() {
    return (
      <div>
        { Object.keys( this.props.authors ).map( ( author_id ) => {
          return <Author key={author_id} author_id={author_id} />;
        } ) }
      </div>
    );
  }
}
AuthorList = connect( mapStateToPropsAL )(AuthorList);

const mapStateToPropsA = ( state, ownProps ) => {
  return ( {
    author : state.reduceAuthors.authors[ownProps.author_id]
  } );
};

class Author extends React.Component {

  render() {
    if ( this.props.author.name === 'Bob Baker' ) {
      alert( "Rendering Bob!" );
    }

    return (
      <div>
        <p>Name: {this.props.author.name}</p>
        <p>Bio: {this.props.author.bio}</p>
      </div>
    );
  }
}
Author = connect( mapStateToPropsA )( Author );


const mapStateToPropsPL = ( state ) => {
  return ( {
    authors : state.reduceAuthors.authors,
    publications : state.reduceAuthors.publications
  } );
};


class PublicationList extends React.Component {

  render() {
    console.log( 'Rendering PublicationList' );
    let authors = this.props.authors;
    let publications = this.props.publications;
    return (
      <div>
        { Object.keys( publications ).map( ( publication_id ) => {
          return <Publication key={publication_id} publication={publications[publication_id]} authors={authors} />;
        } ) }
      </div>
    );
  }
}
PublicationList = connect( mapStateToPropsPL )( PublicationList );


class Publication extends React.Component {

  render() {
    console.log( 'Rendering Publication' );
    let authors = this.props.authors;
    let publication_authors = this.props.publication.authors.reduce( function( obj, x ) {
      obj[x] = authors[x];
      return obj;
    }, {} );

    return (
      <div>
        <p>Title: {this.props.publication.title}</p>
        <div>Authors:
          <AuthorList authors={publication_authors} />
        </div>
      </div>
    );
  }
}

const mapDispatchToProps = ( dispatch ) => {
  return ( {
    changeAlice : ( author ) => {
      dispatch( updateAuthor( author ) );
    },
    changeUnused : () => {
      dispatch( updateUnused() );
    }
  } );
};

class TestApp extends React.Component {
  constructor(props) {
    super(props);
  }

  render() {
    return (
      <div>
        <p>
          <span onClick={ () => { this.props.changeAlice( this.props.authors['1'] ); } }><b>Click to Change Alice!</b></span>
        </p>
        <p>
          <span onClick={ () => { this.props.changeUnused(); } }><b>Click to Irrelevant State!</b></span>
        </p>

        <div>Authors:
          <AuthorList authors={this.props.authors} />
        </div>
        <div>Publications:
          <PublicationList authors={this.props.authors} publications={this.props.publications} />
        </div>
      </div>
    );
  }
}
TestApp = connect( mapStateToPropsAL, mapDispatchToProps )( TestApp );

let store = createStore( testReducers );

ReactDOM.render(
  <Provider store={store}>
    <TestApp />
  </Provider>,
  document.getElementById( 'test' )
);

Upvotes: -3

jpdelatorre
jpdelatorre

Reputation: 3593

I think using Redux would make your app efficient and easier to manage.

Having a global state referred to as Redux store, it allows any of your component to subscribe to a slice of the store and re-render when there are changes to those data.

In your example, the redux way of implementing it would be your AuthorList component will subscribe to the state.authors object and if any component within or outside the AuthorList component updated the state.authors, only the AuthorList component will re-render (and those that are subscribed to it).

Upvotes: 2

Jim Stewart
Jim Stewart

Reputation: 17323

You should use an immutability helper, per React's documentation. This provides a mechanism for updating part of the state, and handles any required cloning for you, with as little duplication as possible.

This allows you do something like:

this.setState(update(this.state, { authors: { $set: { ... } } }));

It will only re-render components that are affected by the change.

Upvotes: 3

Your component state should only really contain internal state values.

You should look into storing more complex state that is needed in multiple components with Redux.

Upvotes: 0

Related Questions