reduxjs / redux-toolkit

The official, opinionated, batteries-included toolset for efficient Redux development
https://redux-toolkit.js.org
MIT License
10.71k stars 1.17k forks source link

Suggestion: add syntax or examples for nested slices #259

Closed tisoap closed 4 years ago

tisoap commented 4 years ago

Currently redux-toolkit does not provide (or I couldn't find) an integrated way to define nested slices/reducers.

To split some slice logic into smaller slices, I'm currently doing something like this:

import { createSlice } from '@reduxjs/toolkit'

// import the reducer, initial state and relevant actions from the desired slice
import otherReducer, {
  complexAction,
  initialState as otherReducerInitialState
} from './otherReducer'

const myReducer = createSlice({
  name: 'myReducer',
  initialState: {
    someData: 'hello',
    // manually bind initial state
    someComplexData: otherReducerInitialState
  },
  reducers: {
    doStuff: (state, action) => {
      // ...
    },
  },
  extraReducers: {
    // manually bind each action
    [complexAction]: (state, action) => {
      // manually foward the state and action to the nested reducer
      state.someComplexData = otherReducer(state.someComplexData, action)
    },
  }
})

export const { doStuff } = myReducer.actions
export default myReducer.reducer

The above example works, but it's a lot of boilerplate code, and I can't shake the felling there should be a better way to describe nested slices.

markerikson commented 4 years ago

Can you clarify what you mean by "nested slices"? What do you want the actual state to look like?

tisoap commented 4 years ago

Basically I want to have pieces of state in my slice that are managed by other slices. In the same sense that the root reducer is compromised by several reducer functions combined, I want to create a slice that is compromised by several slices combined.

It think something like this is already achievable using nested calls to combineReducers, but i'd like an example or built-in syntax for use with redux-toolkit.

In my example above, if otherReducer returned { foo: 'bar', john: 'doe }, the state produced by the main slice would be:

{
  someData: 'hello',
  someComplexData: { foo: 'bar', john: 'doe' }
}
markerikson commented 4 years ago

Nested use of combineReducers would still be our recommendation. We briefly discussed having some kind of combineSlices over in #91 (particularly https://github.com/reduxjs/redux-toolkit/issues/91#issuecomment-456717506 ), but concluded that it would be adding too much specific abstraction and the desired behavior was unclear.

tisoap commented 4 years ago

I see, thanks for your quick response. I still think it's a shame some combineSlices was not implemented, it would suit my use case very well.

If using nested combineReducers is the recommendation for this situation, it would be neat to have this described somewhere in the official docs.

markerikson commented 4 years ago

Yeah, not sure where that mention should go exactly, but please feel free to file a PR that adds that note.

agrinko commented 4 years ago

I had a similar use-case, and here is how I solved it combining the power of combineReducers and createSlice. The solution is quite fresh, not tested well, so any suggestions are welcome.

Some background: I have a sub-state called "associatedItems". It includes some entities (units, categories, items and formulas) that should be loaded in parallel. So in the associatedItems sub-state, I want to keep all the nested entities (they all have their own reducers, too) and an indication of the loading process ("loading" and "loaded" flags). I also have a thunk that fetches them all in parallel from the server (in the code below), and selectors that establish some associations between the nested items - but that's unrelated.

import { Action, createSlice, combineReducers } from '@reduxjs/toolkit';
import reduceReducers from 'reduce-reducers';

import { AppThunk } from 'store/store.d';
import { UnitsState, reducer as units, actions as unitsActions } from 'store/entities/units';
import { CategoriesState, reducer as categories, actions as categoriesActions  } from 'store/entities/categories';
import { ItemsState, reducer as items, actions as itemsActions } from 'store/entities/items';
import { FormulasState, reducer as formulas, actions as formulasActions } from 'store/entities/formulas';
import selectors from './selectors';

// combined associatedItems state
export interface AssociatedItemsState {
  loading: boolean;
  loaded: boolean;
  units: UnitsState;
  categories: CategoriesState;
  items: ItemsState;
  formulas: FormulasState;
}

const { reducer: originalReducer, actions: originalActions } = createSlice({
  name: 'associatedItems',
  initialState: {
    loading: false,
    loaded: false
  } as AssociatedItemsState,
  reducers: {
    startLoading(state) {
      state.loading = true;
      state.loaded = false;
    },
    finishLoading(state) {
      state.loading = false;
      state.loaded = true;
    }
  }
});

// reducer for the nested entities (aka "associated items")
const nestedEntitiesReducer = combineReducers({
  loading: s => s,
  loaded: s => s,
  items,
  units,
  categories,
  formulas
});

// combine original reducer and nested reducers
const reducer = reduceReducers<AssociatedItemsState>(originalReducer, nestedEntitiesReducer);

// extend actions with a thunk
const actions = {
  ...originalActions,
  loadAssociatedItems: (): AppThunk => async dispatch => {
    dispatch(originalActions.startLoading());

    await Promise.all([
      // each dispatch here actually returns a Promise which always fulfills, no need to catch rejections
      dispatch(itemsActions.read()),
      dispatch(categoriesActions.read()),
      dispatch(unitsActions.read()),
      dispatch(formulasActions.read())
    ]);

    dispatch(originalActions.finishLoading());
  }
};

// export them all
export {
  reducer,
  actions,
  selectors
};

It is almost the same as combining nested reducers with combineReducers, but instead of attaching sub-state to some property of the current state, I just mix it into the current state slice with a spread operator.

I could go the other way and use separate nested reducer for { loading, loaded } state, and combine it with others using combineReducers. But I didn't want to introduce extra nesting level for such a simple case.

UPDATED: used reduceReducers utility, as suggested by @markerikson below. Here is the previous piece of code, for reference:

// combine original reducer and nested reducers
const reducer = (state: AssociatedItemsState, action: Action) => ({
  ...state,
  ...originalReducer(state, action),
  ...nestedEntitiesReducer(state, action)
});
markerikson commented 4 years ago

@agrinko : the const reducer, as written, is going to always return a new object reference even if the dispatched action didn't cause any real state updates. That will likely lead to unnecessary UI re-renders.

Instead, consider using https://github.com/redux-utilities/reduce-reducers , per https://redux.js.org/recipes/structuring-reducers/beyond-combinereducers#sharing-data-between-slice-reducers .

agrinko commented 4 years ago

@markerikson thank you, I didn't know that. Updated my code above using reduceReducers. And I had to update nestedEntitiesReducer with dumb reducers for loading and loaded properties to satisfy TypeScript. It was complaining that nestedEntititesReducer's state didn't have all the needed properties, which makes sense.

markerikson commented 4 years ago

Yeah, combineReducers is opinionated that way - it assumes all fields get handled by individual slice reducers, which is really more meant for top-level chunks of state vs individual fields.

We've had requests to remove that warning, but opted not to do so. Note that there's a million re-implementations of combineReducers out there, so it's easy to use one that doesn't have that warning if you'd like.

theogravity commented 4 years ago

What's the proper way to deal with individual fields? I'm trying to convert an existing project (that does not use redux) that has a state structure that looks like this:

{
  project: {
    id,
    collections,
    tables,
    document
  },
  user: {}
}

The only individual field in that is id, and the others are complex nested structures, which I've created slices for.

I'm attempting to do the following for defining the project state - what should I be doing for id?

import { createSlice, combineReducers } from '@reduxjs/toolkit'
import reduceReducers from 'reduce-reducers'

import collectionsReducer from './collections.slice'
import tablesReducer from './tables.slice'
import documentsReducer from './documents.slice'

const projectSlice = createSlice({
  name: 'project',
  initialState: {
    id: null
  },
  reducers: {
    setProjectId(state, action) {
      state.id = action.payload.id
      return state
    }
  }
})

const nestedEntitiesReducer = combineReducers({
  id: // not sure what goes here for 'id',
  collections: collectionsReducer,
  tables: tablesReducer,
  document: documentsReducer
})

const reducer = reduceReducers(
  projectSlice.reducer,
  nestedEntitiesReducer
)

export const {
  setProjectId
} = projectSlice.actions

export default reducer
theogravity commented 4 years ago

I figured it out after looking at @agrinko 's code a few times:

const nestedEntitiesReducer = combineReducers({
  // Had to set a default to null or it would throw
  id: (s = null) => s,
  ...
})
plandem commented 4 years ago

reduceReducers will return a flat reducer that will use shared state for each provided reducer, but keep in mind about initialState, since each slice has own initialState.

jessepinho commented 3 years ago

I made a combineSlices helper function to make it easy to adapt @agrinko's suggestion to any use case (note that it requires that you install reduce-reducers):

import { combineReducers } from "@reduxjs/toolkit";
import reduceReducers from "reduce-reducers";

/**
 * Combines a slice with any number of child slices.
 *
 * This solution is inspired by [this GitHub
 * comment](https://github.com/reduxjs/redux-toolkit/issues/259#issuecomment-604496169).
 *
 * @param sliceReducer - The reducer of the parent slice. For example, if your
 * slice is called `mySlice`, you should pass in `mySlice.reducer`.
 * @param {object} sliceInitialState - The initial state object passed to
 * `createSlice`. This is needed so that we know what keys are in the initial
 * state.
 * @param {object} childSliceReducers - An object of child slice reducers, keyed
 * by the name you want them to have in the Redux state namespace. For example,
 * if you've imported child slices called `childSlice1` and `childSlice2`, you
 * should pass in this argument as `{ childSlice1: childSlice1.reducer,
 * childSlice2: childSlice2.reducer }`. NOTE: The name of each child slice
 * should reflect its place in the namespace hierarchy to ensure that action
 * names are properly namespaced. For example, the `name` property of the
 * `childSlice1` slice should be `mySlice/childSlice1`, not just `childSlice1`.
 */
const combineSlices = (sliceReducer, sliceInitialState, childSliceReducers) => {
  const noopReducersFromInitialState = Object.keys(sliceInitialState).reduce(
    (prev, curr) => {
      return {
        ...prev,
        [curr]: (s = null) => s,
      };
    },
    {}
  );

  const childReducers = combineReducers({
    ...childSliceReducers,
    ...noopReducersFromInitialState,
  });

  return reduceReducers(sliceReducer, childReducers);
};

export default combineSlices;

Note that you need to pass it the initial state from the parent slice so that it knows what keys to make dummy no-op reducers for. Thus, here's an example of how it could be used:

import mySlice2 from "./mySlice2"
import mySlice3 from "./mySlice3"
import combineSlices from "./combineSlices"

// Note that `initialState` is defined as a variable before being passed
// to `createSlice` since it will need to also be passed to `combineSlices`.
const initialState = {
  count: 0,
}

const mySlice = createSlice({
  name: "mySlice",
  initialState,
  reducers: {
    setCount(state, action) {
      state.count = action.payload
    },
  }
})

export default combineSlices(mySlice.reducer, initialState, {
  mySlice2: mySlice2.reducer,
  mySlice3: mySlice3.reducer,
})
dancingfrog commented 10 months ago

After defining combined slices in this way ^ (RE: @agrinko and @jessepinho), how do you import them and add them to your store? Any examples of defining selectors (for "nested" state) and passing them to useSelector?

Is the discussion above outlining a pattern that is supported by the Redux devs or is there another way to approach updating sub-properties of state that allows one to use separate reducers/selectors for the parent state object and child state objects?

HemantNegi commented 2 months ago

Here is gist I extracted from my project which allows to create nested slices - https://gist.github.com/HemantNegi/27bf13b9f7ded5ae420f21932b082ee8

NOTE: nesting is not recommended in general as there are some problems associated with it. eg. The state diff calculation will be slow, no access to parent slice state from child slice (can be overcomed with a custom middleware)

P.S. I did not used this approach, instead normalised my state shape as described here - https://redux.js.org/usage/structuring-reducers/normalizing-state-shape

markerikson commented 2 months ago

There might actually be a better pattern available now. I suggested this to someone the other day:

// however you are making this reducer - createReducer, createSlice, combineReducers, etc
const additionalReducer = createReducer() 

const mySlice = createSlice({
  name: "mySlice",
  initialState, // however you need to make this,
  reducers: {
    // whatever case reducers here
  },
  extraReducers: (builder) => {
    builder.addMatcher(
      // _always_ give this reducer a chance to run too
     () => true,
     additionalReducer
    );
  }
})