Search code examples
reactjsreduxnext.jsreselect

next js with redux and reselect


I am having a problem that when I use reselect with next.js and redux the component won't re render when redux state has been changed by the reducer.

In the container I have a memoized selector function but the selector is called before the container and container is not re rendered after the CATEGORIES_LOADING_SUCCEEDED action.

When I do not memoize the selector it works but that would re render the menu every time something in the state changed.

Full code is here

Here is some information that may be relevant:

pages/index.js

import React from "react";
import Menu from "../components/Menu";

function HomePage() {
  return <Menu />;
}

export default HomePage;

components/Menu.js

import React, { memo, useMemo } from "react";
import { useSelector } from "react-redux";
import { selectCategoriesNested } from "../store/selectors";
import { useCategories } from "../hooks";

function Menu(props) {
  const { categories } = props;

  return (
    <pre>{JSON.stringify(categories, undefined, 2)}</pre>
  );
}
const MemoMenu = memo(Menu);
const MenuContainer = props => {
  console.log("MenuContainer start");
  useCategories();
  const categories = useSelector(selectCategoriesNested);
  const newProps = useMemo(
    () => ({
      ...props,
      categories
    }),
    [props, categories]
  );
  console.log("MenuContainer end", categories);
  return <MemoMenu {...newProps} />;
};
export default MenuContainer;

store/initStore.js (used by _app.js)

import { createStore, applyMiddleware } from "redux";
import { composeWithDevTools } from "redux-devtools-extension";
import thunkMiddleware from "redux-thunk";
import {
  CATEGORIES_LOADING,
  CATEGORIES_LOADING_SUCCEEDED
} from "./actions";

const initState = {
  categories: {
    requested: false,
    data: {}
  }
};

const rootReducer = (state = initState, action) => {
  const { type, payload } = action;
  console.log("reducer:", type);
  if (type === CATEGORIES_LOADING) {
    return {
      ...state,
      categories: { ...state.categories, requested: true }
    };
  }
  if (type === CATEGORIES_LOADING_SUCCEEDED) {
    return {
      ...state,
      categories: {
        ...state.categories,
        data: payload.reduce((categories, category) => {
          categories[category.id] = category;
          return categories;
        }, state.categories.data)
      }
    };
  }
  return state;
};

export const initStore = (initialState = initState) => {
  return createStore(
    rootReducer,
    initialState,
    composeWithDevTools(applyMiddleware(thunkMiddleware))
  );
};

store/selectors.js

import { createSelector } from "reselect";

export const selectCategories = state => state.categories;
export const selectCategoriesRequested = createSelector(
  selectCategories,
  categories => categories.requested
);
export const selectCategoriesData = createSelector(
  selectCategories,
  categories =>
    console.log(
      "selector returning:",
      Object.keys(categories.data).length
    ) || categories.data
);

const nestCategories = data => {
  const categories = Object.values(data).map(category => ({
    ...category,
    subCategories: []
  }));
  const rootCategories = categories.filter(
    category => !category.parent
  );
  const categoriesMap = categories.reduce(
    (categories, category) =>
      categories.set(category.id, category),
    new Map()
  );
  categories.forEach(category => {
    if (
      category.parent &&
      category.parent.typeId === "category"
    ) {
      const parent = categoriesMap.get(category.parent.id);
      parent && parent.subCategories.push(category);
    }
  });
  return rootCategories;
};

// the following does not re render menu when categories
//  are loaded
export const selectCategoriesNested = createSelector(
  selectCategoriesData,
  nestCategories
);
// the following works but breaks menu as pure component
// export const selectCategoriesNested = state =>
//   nestCategories(selectCategoriesData(state));

hooks.js

import { selectCategoriesRequested } from "./store/selectors";
import { loadCategories } from "./store/actions";
import { useDispatch, useSelector } from "react-redux";
import { useEffect } from "react";

export const useCategories = query => {
  const dispatch = useDispatch();
  const requested = useSelector(selectCategoriesRequested);
  useEffect(() => {
    if (!requested && process.browser) {
      dispatch(loadCategories(query));
    }
  }, [dispatch, query, requested]);
};

output of the log:

initStore.js:18   reducer: @@INIT
Menu.js:15        MenuContainer start
selectors.js:10   selector returning: 0
Menu.js:25        MenuContainer end []
initStore.js:18   reducer: CATEGORIES_LOADING
selectors.js:10   selector returning: 0
Menu.js:15        MenuContainer start
Menu.js:25        MenuContainer end []
initStore.js:18   reducer: CATEGORIES_LOADING_SUCCEEDED
selectors.js:10   selector returning: 1

If I change the selector to:

export const selectCategoriesNested = state =>
  nestCategories(selectCategoriesData(state));

I get an 2 extra console logs:

Menu.js:15    MenuContainer start
Menu.js:25    MenuContainer end [{…}]

What I wonder is why the selector is called before MenuContainer start but the selector should be called from MenuContainer.

UPDATE

Added some logs and looks like nextjs is mutating the store in some way. Added logs and after CATEGORIES_LOADING_SUCCEEDED action the selector logs from Same [] to Same ["1"]. This should not be possible unless data was mutated but I can't see how my reducer mutates the data.

I may be wrong but this looks like a bug so I submitted a bug report


Solution

  • This is embarrising but it was me who made the error. The reducer does mutate state.categories.data in the reduce function:

    payload.reduce((categories, category) => {
      categories[category.id] = category;
      return categories;
    }, state.categories.data);
    

    I pass state.categories.data as the second argument to payload.reduce and in the reducer function I mutate it. This reducer function should be:

    payload.reduce((categories, category) => {
      categories[category.id] = category;
      return categories;
    }, {...state.categories.data});