andrewngu / sound-redux

A Soundcloud client built with React / Redux
http://soundredux.io
GNU General Public License v3.0
5.01k stars 875 forks source link

normalizr question #17

Closed terrysahaidak closed 8 years ago

terrysahaidak commented 8 years ago

How did your reducer became looks like that?

const initialState = {
    playlists: {},
    songs: {},
    users: {}
};

export default function entities(state = initialState, action) {
    if (action.entities) {
        return merge({}, state, action.entities);
    }

    return state;
}

Can you explain it with some examples, because i don't understand it :disappointed:

andrewngu commented 8 years ago

Basically I followed the example here: https://github.com/rackt/redux/blob/master/examples/real-world/reducers/index.js

I guess it looks strange because we're used to seeing a switch statement on the action.type in the reducer. This reducer basically looks for the entities property of the action and does the same operation on the entities state (merging the new entities object), so it saves me from having to write something like:

export default function entities(state = initialState, action) {
    switch (action.type) {
        case RECEIVE_USER_ENTITIES:
            return merge({}, state, {
                users: action.entities.users
            }};

        case RECEIVE_PLAYLIST_ENTITIES:
            return merge({}, state, {
                users: action.entities.playlists
            }};

        case RECEIVE_SONG_ENTITIES:
            return merge({}, state, {
                users: action.entities.songs
            }};

        default:
            return state;
    }
}

Also, if i wrote it like that :point_up: , I would then have to dispatch two actions to, for example, update a users likes.

function receiveUserLikes(normalized) {
   return {
        type: types.RECEIVE_USER_LIKES,
        songs: normalized.result
   };
}

function receiveSongEntities(normalized) {
    return {
        type: types.RECEIVE_SONG_ENTITIES,
        entities: normalized.entities
   };
}

Instead, I can just use a single action

function receiveUserLikes(normalized) {
   return {
        type: types.RECEIVE_USER_LIKES,
        songs: normalized.result,
        entities: normalized.entities
   };
}

Since the entities property is set in this action, it'll trigger an update in the entities reducer AND the reducer for updating the user's likes. You just have to remember that an action will "pass through" every reducer and that you can handle actions however you'd like, not just switch statements.

terrysahaidak commented 8 years ago

thanks

what about schemas?

is it really helps you to do stuff easy to code?

andrewngu commented 8 years ago

Yep, schemas are helpful and are needed when using normalizr.

Say I get an API response of songs that looks like this:

const response = [
    {
        id: 1,
        title: 'Steal the show',
        user: {
            id: 101,
            name: 'Kygo'
        }
    },

    {
        id: 2,
        title: 'High for this',
        user: {
            id: 102,
            name: 'The Weeknd'
        }
    }
]

First I just need to define the song schema and the user schema

let songSchema = new Schema('songs');
let userSchema = new Schema('users');

Looking at the API response, the user object is nested inside the song schema in the user property, so we need to update our songSchema

songSchema.define({
    user: userSchema
});

So now we can normalize the response

const normalized = normalize(response, arrayOf(songSchema);
// using arrayOf because the response is an array of songs :)

console.log(normalized);
{
    result: [1, 2],
    entities: {
        users: {
            101: {
                name: 'Kygo'
            },
            102: {
                name: 'The Weeknd'
            }
        },

        songs: {
            1: {
                title: 'Steal the show'
            },
            2: {
                title: 'High for this'
            }
        }
    }
}

Say these are songs in a playlist. I can now store the playlist as an array of song ids [1, 2] instead of an array of objects. When I need to access the song's title, it would look like entities.songs[songId].title. Why is this useful? If I have two playlists with the same song [1,2] and [2], my store only contains the data needed for song 2 in entities.songs once. Hope that helps.

terrysahaidak commented 8 years ago

It's really helpful, now i unerstand it. Thank you so much for your help man :)

andrewngu commented 8 years ago

Cool! :+1: Glad I could help.

ryannealmes commented 8 years ago

Could you perhaps elaborate on the importance of the result array returned by normalizr? Why does it only return the ids of the parent object and not the other entities nested inside it? For example in your case you have songs and users, but it only returns the ids of your songs in the result array. It doesn't have for example an object with the list of ids for each entity in your data structure. This feels a bit random. Not really sure what it's purpose is.

Thanks for the write up. Really helpful!

andrewngu commented 8 years ago

Say we have two playlists. Which approach seems more efficient?

const playlistA = [
  {
    id: 1,
    title: 'Far Behind',
    user: {
      id: 200,
      name: 'Oleska',
    }
  },
  {
    id: 2,
    title: 'Celebration',
    user: {
      id: 200,
      name: 'Oleska',
    }
  },
  {
    id: 3,
    title: 'Back to Back',
    user: {
      id: 205,
      name: 'Drake',
    }
  },
];

const playlistB = [
  {
    id: 1,
    title: 'Far Behind',
    user: {
      id: 200,
      name: 'Oleska',
    }
  },
  {
    id: 4,
    title: 'Tuesday',
    user: {
      id: 205,
      name: 'Drake',
    }
  },
];

And this approach, using normalizr:

const playlistA = [1, 2, 3];
const playlistB = [1, 4];

const entities = {
  songs: {
    1: { title: 'Far Behind', userId: 200 },
    2: { title: 'Celebration', userId: 200 },
    3: { title: 'Back to Back', userId: 205 },
    4: { title: 'Tuesday', userId: 205 },
  },
  users: {
    205: { name: 'Drake' },
    200: { name: 'Oleska' },
  }
};

In the first approach, we have objects defining the same song (since they appear in two different playlists)--as well as multiple objects defining the same user. In the second approach we have a single source of truth in describing each song / user.

Not only is the second approach more memory efficient, but it also eliminates chances for inconsistency within your application. Say, I'm actually logged in as Drake and I change my name. If the data for that user is defined in a single place in the entities.users object, any component that references my user id will be updated as well. The alternative would be to iterate through every song, and update user object where the song.user.id is a match.

Does that help a bit?

ryannealmes commented 8 years ago

Ye this makes 100% sense. My question was more around the implementation of normalizr I guess.

If you look at the example from the normalizr documentation below

articles: article*

article: {
  author: user,
  likers: user*
  primary_collection: collection?
  collections: collection*
}

collection: {
  curator: user
}
Without normalizr, your Stores would need to know too much about API response schema.
For example, UserStore would include a lot of boilerplate to extract fresh user info when articles are fetched:

// Without normalizr, you'd have to do this in every store:

AppDispatcher.register((payload) => {
  const { action } = payload;

  switch (action.type) {
  case ActionTypes.RECEIVE_USERS:
    mergeUsers(action.rawUsers);
    break;

  case ActionTypes.RECEIVE_ARTICLES:
    action.rawArticles.forEach(rawArticle => {
      mergeUsers([rawArticle.user]);
      mergeUsers(rawArticle.likers);

      mergeUsers([rawArticle.primaryCollection.curator]);
      rawArticle.collections.forEach(rawCollection => {
        mergeUsers(rawCollection.curator);
      });
    });

    UserStore.emitChange();
    break;
  }
});
Normalizr solves the problem by converting API responses to a flat form where nested entities are replaced with IDs:

{
  result: [12, 10, 3, ...],
  entities: {
    articles: {
      12: {
        authorId: 3,
        likers: [2, 1, 4],
        primaryCollection: 12,
        collections: [12, 11]
      },
      ...
    },
    users: {
      3: {
        name: 'Dan'
      },
      2: ...,
      4: ....
    },
    collections: {
      12: {
        curator: 2,
        name: 'Stuff'
      },
      ...
    }
  }
}

The final object returns a list with article ids. What is the point of that array? And why does it only have reference ids to the articles and not the users or collections. Maybe it's a none issue that lists of the other ids haven't been returned as well?

Thanks again! Learning lots!

andrewngu commented 8 years ago

The result array refers to the playlist, which is an ordered list of songs. Let's say I have a Playlist component and when I render this list of songs I'd like to maintain the order. I would use that array to render my songs, while referencing the entities object.

render() {
  const { entities} = this.props; // { songs: { 1: {...}, 2: {...}, 3: {...}, 4: {...}, 5: {...} } }
  const { playlist } = this.props; // [1, 2, 3] `result` array, songs in my playlist 
  return (
    <div>
      { playlist.map(songId => <Song song={entities.songs[songId]} /> }
   </div>
  );
}