Closed tisoap closed 4 years ago
Can you clarify what you mean by "nested slices"? What do you want the actual state to look like?
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' }
}
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.
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.
Yeah, not sure where that mention should go exactly, but please feel free to file a PR that adds that note.
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)
});
@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 .
@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.
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.
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
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,
...
})
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.
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,
})
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?
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
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
);
}
})
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:
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.