Avoid re-rendering components using array.map with React+Redux
Asked Answered
V

0

10

First of all, I have already gone through:

And have learnt a lot, but still can't figure out how to work out my case.

The Mission

I am doing a music player and I have normalized my store to look like this:

{
    albums : {
        album1 : {
            name  : "...",
            songs : ["s1, s2"]
        },
        album2 : {
            name  : "...",
            songs : ["s3, s4"]
        }
    },
    songs : {
        s1 : {
            id : "s1",
            title : "title",
            artist : "name"
        },
        s2 : { ... },
        s3 : { ... }
    }
}

So, I have a view that loads a bunch of albums and displays each one with its corresponding songs in something like this:

<Album id={albumId} />

And the Album components looks like this:

class Album extends Component {
    
    componentDidMount() {
        this.props.loadAlbum(this.props.albumId)
    }

    render() {
        <div> ... </div>
    }
}

mapStateToProps = (state, props) => {
    const album = state.albums[props.albumId]
    return {
        album : album,
        songs : album.songs.map( id => state.songs[id] )
    }
}

(I am omitting some Redux's boilerplate to enhance readability, but if something feels missing please let me know to add it) loadAlbum is the action that loads the album with its songs and I have two reducers (albumsReducer and songsReducer) that set the album's metadata in store.albums and "pushes" the new songs of that album into store.songs, respectively.

This is the simplified songsReducer:

(state = initialState.songs, action) => {
    if( types.PUT_SONGS_RESULT ) {
        return { ...state, ...payload.songs }
    }
}

The Problem

The thing is that, of course, as I am creating a new "songs" array every time the store gets updated in mapStateToProps, the component of "album1" is getting re-rendered every time every other album fetches its songs (because even though the result of "album.song.map" is the same by value for that album, it is not the same array. i.e. [{a:1}] !== [{a:1}]).

I tried moving the mapping to the following selector using reselect:

const getAlbum = (state, props) => state.albums[props.albumId]

const getSongs = (state, props) => state.songs

export const makeGetSongsOfAlbums = () => {
    return createSelector (
        [ getAlbum, getSongs ],
        (album, songs) => {
            return album ? album.songs.map( id => songs[id] ) : []
        }
    )
}

Which gets connected like this:

mapStateToProps = (state, ownProps) => {
    const getSongsOfAlbum = makeGetSongsOfAlbums()
    return {
        "album" : state.albums[ownProps.albumId],
        "songs" : getSongsOfAlbum(state, ownProps)
    }
}

But it has the same effect because the value gets computed every time one of the arguments of the selector changes and state.songs changes every time one album loads its songs (right?).

So, does anyone have an idea on how I can prevent re-rendering the Album component every time every other album loads? I can implement shouldComponentUpdate, but I thought it could be achieved "automatically" without having to perform the deep comparison manually, though definitely I am no React expert so if my hunch of staying away from shouldComponentUpdate as much as possible is wrong, please let me know.

The Journey

You might be wondering why I am normalizing data like this if it'd be simpler to have the store like:

{
    albums : {
        album1 : {
            name : "...",
            songs : [{id : "s1"}, {id : "s2"}]
        },
        album2 : {
            name : "...",
            songs : [{id : "s3"}, {id : "s4"}]
        }
    }
}

Or even loading songs in the local state of each Album.

Well, the thing is that I am also loading playlists and I need to support the feature to label songs as favourites, so this was starting to get messy when I was displaying the same song in several places in the same screen (i.e. the music player and any list of songs) because when I label one song as "favourite", I want it to be labeled everywhere in the app so each component that is showing it says "hey! this song is already a favourite! I will show a little star in here and won't let the user re-favourite because the API fails if I try to (can't do anything about it)" and having to update the song's status in many places in the store and in local states inside components didn't sound like a good thing. Instead, it sounded more like songs should be in the global store so I just have to update each song by id in state.songs and it should also increase performance (or that's what I thought).

So, for instance, when I load a playlist I am following the same pattern as an album:

playlists : {
    playlist1 : {
        name : "...",
        songs : ["s1, s2"]
    },
    playlist2 : {
        name : "...",
        songs : ["s3, s4"]
    }
}

And the playlists puts its songs in state.songs. Still if this approach sounds like its getting too complicated (because indeed I thought it would be simpler) and you don't think it's the way to go, please let me know!

EDIT replying to Will Jenkins:

The 'songs' array (actually it is an object. I have edited the sample in the code above to make it clearer) in the root of the store is where all the songs that are going to be currently displayed are going to be. For example, if I load view which displays the songs of a playlist, I would clear that object and then put songs coming from the API in there. In the case of the albums, if that view needs to display 5 albums with its songs, then again I would clear that object and put songs from every album in there so then every album can filter its own songs.

'because even though the result of "album.song.map" is the same by value for that album, it is not the same array' this means that [{a:1}] !== [{a:1}] because they look the same, but they are not the same array (I have edited my original statement to include this example). Even if they held the same object, they would still be different:

const a = {a:1}
[a] === [a] // this would yield false

About the issue with favouriting, yes, there should be only one entry per song in the store (which actually is the purpose of the 'songs' object). I was trying to explain the issue I had before normalizing data where I had many entries of the same song in many places.

Vookles answered 2/8, 2019 at 6:34 Comment(7)
Couple of questions - what is the 'songs' array in the root of your store? also, what does 'because even though the result of "album.song.map" is the same by value for that album, it is not the same array' mean?Fortuna
also, I'm a bit confused when you talk about the problem of favouriting songs, particularly "and having to update the song's status in many places in the store and in local states inside components didn't sound like a good thing." - why many places? There should be one entry per song in your store. You should be able to add a favourite flag to one song in one place and it updates everywhere.Fortuna
@WillJenkins thanks for taking the time for reading all my confusing stuff! I have edited my question with my repliesVookles
Is this 'rerender' a major performance issue or does it detract from the UX? shouldComponentUpdate is intended for performance improvements and usually it is recommended to use React.PureComponent before shouldComponentUpdate.Grey
@Grey I am rendering 3 albums at a time and the UI feels slow, but still I just want to know if I am doing something wrong as a component is rerendering without having toVookles
@Vookles are you still faced with this issue? I am working on how to prevent rerendering caused by array filtering in selectors. I might be able to come up with a solutionGesellschaft
Look at this: staleclosures.dev/preventing-list-rerendersExternalization

© 2022 - 2025 — McMap. All rights reserved.