Redux normalizr + dealing with reduced responses
Asked Answered
C

2

7

Normalizr is great at creating structured JSON repositories of entities.

We have many cases displaying lists of data e.g. posts that have been normalised. Where posts are listed the API response is limited to a few key fields.

We also have cases where we display one of these posts although we now need to fetch the FULL JSON entity from the API with all the fields.

How is it best to deal with this?

A a seperate reducer, thunk/saga, selectors and actions?

B simply insert the extended version of thepost fetched from the API into the reducer. Reusing the selectors etc from before?

Cresting answered 1/7, 2016 at 5:1 Comment(0)
H
8

Think of the app's state as a database. I suggest you to use this state shape:

{
  entities: {
    // List of normalized posts without any nesting. No matter whether they have all fields or not.
    posts: {
      '1': {
        id: '1',
        title: 'Post 1',
      },
      '2': {
        id: '2',
        title: 'Post 2',
      }
    },
  },
  // Ids of posts, which need to displayed.
  posts: ['1', '2'],
  // Id of full post.
  post: '2',
}

First of all, we are creating our normalizr schemas:

// schemas.js
import { Schema, arrayOf } from 'normalizr';

const POST = new Schema('post');
const POST_ARRAY = arrayOf(POST);

After success response, we are normalizing response data and dispatching the action:

// actions.js/sagas.js
function handlePostsResponse(body) {
  dispatch({
    type: 'FETCH_POSTS',
    payload: normalize(body.result, POST_ARRAY),
  });
}

function handleFullPostResponse(body) {
  dispatch({
    type: 'FETCH_FULL_POST',
    payload: normalize(body.result, POST),
  });
}

In reducers, we need to create entities reducer, which will be listening all actions and if it has entities key in payload, would add this entities to the app state:

// reducers.js
import merge from 'lodash/merge';

function entities(state = {}, action) {
  const payload = action.payload;

  if (payload && payload.entities) {
    return merge({}, state, payload.entities);
  }

  return state;
}

Also we need to create corresponding reducers to handle FETCH_BOARDS and FETCH_FULL_BOARD actions:

// Posts reducer will be storing only posts ids.
function posts(state = [], action) {
  switch (action.type) {
    case 'FETCH_POSTS':
      // Post id is stored in `result` variable of normalizr output.
      return [...state, action.payload.result];
    default:
      return state;
  }
}

// Post reducer will be storing current post id.
// Further, you can replace `state` variable by object and store `isFetching` and other variables.
function post(state = null, action) {
  switch (action.type) {
    case 'FETCH_FULL_POST':
      return action.payload.id;
    default:
      return state;
  }
}
Highspeed answered 1/7, 2016 at 14:58 Comment(7)
I have a question: Does merge({}, state, payload.entities); mutate the state?Thunderstruck
@Thunderstruck No, as we are passing empty object as first argument, merge function will return new object.Highspeed
This is by far the best answer, we ended up going exactly for this approach. the key is in writing good selectors and filters. Also highly recommend using Immutable JS...!Cresting
I've run into an issue with this configuration whereby the state.entities and state.post and state.posts can end up very temporarily out of sync which causes an interim mapping in reselect (where I denormalize entities and their in-order IDs in my components) which contains undefined entries in an array of e.g. posts. For e.g. state.posts can temporarily contain IDs that are not in state.entities.posts if the posts reducer has not run while the common entities reducer has. Likely not clear without code sample, but figure if somebody has run into this already, they'll understand.Deegan
Everything ends up resolving itself immediately after, but some components can balk if fed an array (only very temporarily) with undefined properties on the first pass. I'm figuring out where to deal with this so it doesn't spit out a temporarily invalid component state (e.g. an array for a dropdown of values with undefined options) which would appear to necessarily be in my selector, but it feels a bit dirty because it needs to work around something outside of itself (two different sources of truth that can very momentarily be out sync). Anybody have some suggestions/guidance?Deegan
How do you configure the store so that the entities reducer listens to all actions? Thank you!Furlong
@Highspeed Thing is, what if the user visits the /singlepost page first, the entities haven't been loaded yet?Furlong
D
3

I agree with both of your two choices and would have come to the same conclusion. But let's have a closer look at them to see an advantage form one over the other:

(B) You can merge the post entities (preview and full representation) as one entity in your reducer, but you would keep track of the result arrays (preview and full representation), which you would get from the normalizr normalized data after the API requests. Then you can easily distinguish afterwards, if you already have the full representation of the post. Your sub-state might look like the following:

const postState = {
  // merged results from PREVIEW api
  previews: [1, 2, 3],

  // merged results from FULL api
  full: [2],

  // all merged entities
  entities: {
    1: {
      title: 'foo1'
    },
    2: {
      title: 'foo2',
      body: 'bar',
    },
    3: {
      title: 'foo3'
    }
  }
}; 

(A) You would have two reducers + actions, one for each representation, to distinguish the entities. Depending on the PREVIEW or FULL posts API request, you would serve one of your reducers via one explicit action. Your sub-states might look like these:

const previewPostState = {
  // merged results from PREVIEW api
  result: [1, 2, 3],

  // all preview entities
  entities: {
    1: {
      title: 'foo1'
    },
    2: {
      title: 'foo2',
    },
    3: {
      title: 'foo3'
    }
  }
}; 

const fullPostState = {
  // merged results from FULL api
  result: [2],

  // all full entities
  entities: {
    2: {
      title: 'foo2',
      body: 'bar'
    }
  }
}; 

From a very high level perspective you can already see that you would have to save duplicated information. The post entity with id: 2 would be saved two times with its title property: one time for previewPostState and one time for fullPostState. Once you want to change the title property in your global state, you would have to do it at two places. One would violate the single source of truth in Redux. That's the reason I would go with choice (B): You have one place for your post entities, but can distinguish clearly their representations by your result arrays.

Derm answered 6/8, 2016 at 10:39 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.