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

Feature discussion: `createSlice` behavior #91

Closed markerikson closed 5 years ago

markerikson commented 5 years ago

There's been some scattered discussion of how createSlice should actually behave, so I wanted to move this into its own thread.

I'll keep the initial thoughts short and open things up for discussion:

Tagging: @modernserf @denisw @Dudeonyx @BTMPL @lkuoch @mattkahl @doxick

modernserf commented 5 years ago

So I think I'm a little unclear on what you want selectors to do here. Right now it seems like a slice always has one selector, and it's in the form of { getCounter: (rootState) => rootState.counter }? Is there any reason for this particular API other than it matches autodux? When would you prefer counter.selectors.getCounter over something like counter.selector?

For "validation", i was imagining you'd create a wrapper for combineReducers that can accept slices and validate that they are in the state slot they claim to be in, like:

function combineReducers(reducersOrSlices) {
  const reducers = {}
  for (const [key, reducerOrSlice] of Object.entries(reducersOrSlices)) {
    if (reducerOrSlice.slice && reducerOrSlice.slice !== key) { throw new Error() }
    reducers[key] = reducerOrSlice.reducer || reducerOrSlice
  }
  return redux.combineReducers(reducers)
}

If you wanted slices to be nestable, you could have it such that giving a slice an id like foo.counter would respond to actions like foo.counter/increment and select (rootState) => rootState.foo.counter, with the path validations propagating through the tree of combineReducers calls.

markerikson commented 5 years ago

@modernserf : the primary reason for the current behavior is that that's what @neurosnap implemented for https://github.com/neurosnap/robodux , and our implementation is a literal port of what is in robodux to start with at the time I pulled it over. (As in, I cloned the robodux repo, changed the TS settings to spit out ES6+, compiled, and copied the JS files into RSK.)

As I said in the other thread, I think autodux actually iterates through all the keys if your state is an object and generates selectors for all of those. It looks like @Dudeonyx 's TS fork of RSK does something similar: https://github.com/Dudeonyx/redux-ts-starter-kit/blob/master/packages/slice/src/selector.ts .

neurosnap commented 5 years ago

@markerikson is correct. The intention was to eventually add more features to the selectors object returned from createSlice.

Overall I'm not happy with selectors returned by createSlice but don't really know how to improve upon it. For me it's really important to see how selectors work, because it is one of the more critical areas in terms of tweaking performance so I'm a little apprehensive on where to take it next. Returning all the keys within initialState as selectors seems fine, but the most important thing for me would be proper typing.

Dudeonyx commented 5 years ago

@neurosnap I eventually figured out the typing for generating additional selectors for all the keys of initialState when it's an object. I've been meaning to make the PR at the robodux repo.

Dudeonyx commented 5 years ago

Also I would like to propose that we change the default selector to have a fixed name instead of the current get${slice} for the following reasons

const { selectors: { getSlice: selectCounter } }  = createSlice({ 
  slice: "counter",
  //...
})
markerikson commented 5 years ago

@Dudeonyx : agreed. Also, I'd prefer to use selectFoo as the prefix rather than get.

lkuoch commented 5 years ago

Here's a suggestion I posted earlier but was closed in favor of this thread.

A possible improvement would be for slices to contain references to other slices.

I was thinking something like this:

Dudeonyx commented 5 years ago

@Ikuoch How do you imagine the referenced slices being used?

lkuoch commented 5 years ago

@Dudeonyx In what context did you mean?

The way I was thinking is that that ideally for an app, you could create a rootSlice and register all your slices with it.

With slices, I think it eliminates the need to have your actions, reducers etc. split across files so that you can have them all in one data structure.

Then in your components, you only need to call the single source of truth, the rootSlice.

I haven't worked out how it would look like or work yet but maybe you can call something like rootSlice.references[slice1]

or...

there could be some magic where if you pass in a string to rootSlice.getSlice('slice3/slice5') it can interpret that as meaning you want to access rootSlice->slice3->slice5.

denisw commented 5 years ago

I want createSlice to be included in some form. We're not removing it.

Fair enough. I will then refrain from the createSlice rant I promised in #82. 😅 I'd like to raise two points, though, that we should be aware of when planning next steps:

Given these points, I propose we create and communicate a razor-sharp definition for what a slice is or is not, and ensure that we don't grow its scope past that definition. Personally, I think we should stay with what we have currently, meaning that a slice is

I see no compelling reason to allow the definition of additional selectors as part of createSlice(). There are no gains in terms of reduced boilerplate, and if we accept that slices are not full-blown Redux modules, then having other selectors not directly attached to the slice object is no issue.

I also don't think it is a good idea to automatically create selectors for attributes of a slice state object. Like auto-generating getters in Java IDE's, it encourages the mindless direct exposition of all state pieces without considering what data is needed by the view layer, and in which form.

Based on the slice definition above, the following are changes we could do to the current function:


[*] As an aside, it should be also noted that slices interact somewhat awkwardly with the Redux "ducks"-style module pattern. As a module author, you have the choice of directly exporting a slice plus any non-slice entities like thunk action creators, making the user guess when to write:

import module from './module'

module.actions.someAction()

and when they need to write instead:

import { someThunkAction } from './module'

someThunkAction()

Or you can hide the slice from the user by making all slice pieces top-level exports, like this:

const slice = createSlice({
  // ...
})

export const select = slice.selectors.getSlice
export const someAction = slice.actions.someAction
// ...

export default slice.reducer

but then you lose the conciseness you used createSlice for in the first place.

This dilemma is not a problem as such - maybe createSlice is just not a good fit for the ducks pattern, and could be called out as such in the documentation.

denisw commented 5 years ago

Another aspect we should consider is composability. As currently defined, slices are not composable at all. Most obviously, the generated slice selector has a hardcoded assumption on where the slice state is placed - namely, under an attribute named after the slice in the root state object. Install the slice reducer anywhere else, and the slice selector breaks.

More subtly, composing slices into bigger slices is not really possible because their means of composition "above" and "below" are asymmetrical. A slice reducer assumes to own a state object attribute (the one defined by the slice name), whereas the sub-reducers of a slice own an action type instead. This is a fundamental mismatch that is hard, or perhaps even impossible, to overcome.

But there are more problems. Try to imagine a combineSlices(...slices) that itself returns a slice:

const counterSlice = createSlice({
  name: 'counter',
  initialState: 0,
  reducers: {
    add: (state, { payload }) => state + payload
  }
})

const listSlice = createSlice({
  name: 'list',
  initialState: [],
  reducers: {
    add: (state, { payload }) => state.concat([payload])
  }
})

const slice = combineSlices(counterSlice, listSlice) 

How would slice look? What would slice.actions.add be - the add action creator of count, or that of list? To remove ambiguity, we could add them as slice.actions['counter/add'] and slice.actions['list/add'], but that would be awkward; or add them as slice.actions.counter.add and slice.actions.list.add, but then the shape is not that of a slice anymore. And what to do if both slices are named "counter"? Alternatively, we could chose to not add the sub-slices' action creators to the composed slice at all, which however would make it less useful. So, it's complicated. And I didn't even get into what to do with the slice reducers...

Now, there are two possible courses of action. One is to rethink slices so that they become composable, which boils down to putting fewer assumptions into them about how they are embedded (e.g., by removing the auto-generated slice selector). The other is to acknowledge that slices offer no or only limited composability - but still offer a lot of convenience for the cases they were designed for - and clearly document the limitations.

BTMPL commented 5 years ago

@lkuoch

With slices, I think it eliminates the need to have your actions, reducers etc. split across files so that you can have them all in one data structure. Then in your components, you only need to call the single source of truth, the rootSlice.

Not sure I understand, but it sounds like you want to just import one root slice and be able to reference all other slices through it, vs. importing specific slices the components (containers) want to work with.

If so, I don't really like the idea - it feels too "magical" for me, and probably typing it in TS would be a mess again.

neurosnap commented 5 years ago

Let me describe how I have been using slices with success in my projects. There are a couple of key ideas that I think are best practices for using redux that avoids some of the arguments against slices.

1) Flat is better than nested. If the redux state is normalized, there is rarely a need for reducers to compose one another (or slices) beyond one combineReducer for the main redux object.

2) Reducers must be simple and only handle their one domain. If a reducer messages needs to be wiped on LOGOUT, that should happen within a logout side-effect. Having reducers listen to actions that are not associated with it leads to spaghetti code. Actions end up having unintended side-effects and the only way to truly understand what an action does is to grep for it in all of its reducers. Instead, keep all state updates colocated in one location that uses the actions that are already created for that reducer. If a lot of reducers need to be updated based on one action, use redux-batched-actions.

3) This is not a ducks replacement, this is a building block for creating modules or packages. Slices are not a 1:1 with modules or ducks, some of my packages have many slices.

// packages/messages/index.js

const messages = createSlice({
  initialState: {},
  reducers: {
    add: (state, action) => { ...state, action.payload },
  },
  slice: 'messages',
});

const messageIdSelected = createSlice({
  initialState: '',
  reducers: {
    set: (state, action) => action.payload,
  },
  slice: 'messageIdSelected',
});

const actions = {
  ...messages.actions,
  ...messageIdSelected.actions
};

const reducers = {
  [messages.slice]: messages.reducer,
  [messageIdSelected.slice]: messageIdSelected.reducer,
};

const getMessageByIdSelected = (state) => {
  const msgs = messages.selector(state);
  const messageId = messageIdSelected.selector(state);
  return msgs[messageId];
}

const selectors = {
  getMessages: messages.selector,
  getMessageIdSelected: messageIdSelected.selector,
  getMessageByIdSelected,
};

function fetchMessages() {
  return (dispatch) => {
    fetch('/messages')
      .then((resp) => resp.json)
      .then((body) => {
        dispatch(actions.addMessages(body));
        dispatch(actions.setMessageIdSelected(body[0].id));
      })
  }
}

const effects = {
  fetchMessages,
}

export { actions, reducers, selectors, effects };

This is how I create packages and I think it works really well. I'm able to have multiple slices, I can define whatever other selectors, actions I want. I can even create my side effect functions in this file.

4) I try to keep my side-effect functions separate from my actions. This makes it clear that this action isn't simply hitting the reducer. Whether it is an action that activates a saga or an action creator, I colocate them inside effects for a package.

It promotes the idea that actions are somehow "owned" by reducers, making it less obvious that, of course, one slice's actions can also be reacted on by another slice. We can mitigate this with documentation.

I actually like that actions are owned by reducers. When I create a slice no other action is allowed to interact with that reducer. This guarantees that other actions don't slip into the reducer. I think this could be a decision by design.

I also don't think it is a good idea to automatically create selectors for attributes of a slice state object.

I think I agree with you here. I currently do not even use selectors returned from createSlice. I would be in favor or removing it completely to avoid any confusion on what createSlice is supposed to do. And like others have said, we technically do not know what part of the state this slice is dealing with, we are assuming a flat structure ... which works based on my recommendations above but is not enforced.

In defense of createSlice, it has vastly reduced my "redux boilerplate" and because all of my actions/reducers are simple by design, I'm able to build on them even more with prebuilt slices like mapSlice and assignSlice like described in #92

I'm able to get the redux side of things created very quickly and spent most of my time in my side-effects.

Overall I think that createSlice is a building block for modules, something else needs to be built on top of it for a packages solution that includes side-effects

denisw commented 5 years ago

Reducers must be simple and only handle their one domain. If a reducer messages needs to be wiped on LOGOUT, that should happen within a logout side-effect.

With this approach, the logout side effect needs to have knowledge about all slices affected by the logout, which creates pretty tight coupling. In practice, this can mean that the logout module knows about pretty much every reducer in the system, which makes it an undesirably tight coupling point. I think we should rather encourage developers to embrace the publish-subscribe nature of Redux and let those slices react to the single logout action that need it; it allows you to introduce a new slice that reacts to logout with fewer changes to the existing Redux modules.

This is not a ducks replacement

I agree, and we should make this as clear as possible.

neurosnap commented 5 years ago

In practice, this can mean that the logout module knows about pretty much every reducer in the system, which makes it an undesirably tight coupling point.

It's either that or every other module needs to be dependent on logout. In terms of hierarchy I think it makes sense for logout to depend on all the other modules. I've worked on large projects that have done it both ways and I think logout importing every module was the most maintainable. I think in theory, letting reducers "subscribe" to any event is really powerful, but at least based on my experience, it has always led to confusing and less maintainable code. I want one place where I can see what happens when a user logs out. I don't want to have to check N number of places to see what's going on.

markerikson commented 5 years ago

I've seen arguments both ways ("multiple slices responding" vs "all the logic in one place").

FWIW, Dan has always said that "multiple slices responding" was the intended usage, and it's something I intend to promote more in the upcoming docs revamp.

Also, while you're always free to organize your reducers and actions however you want conceptually, I personally prefer to put as much logic into the reducers as possible.

Busy at work atm, but one other quick thought: as far as I can tell, createSlice does work as a "ducks" generator just fine. I whipped up a quick CodeSandbox as an example, but here's the important part:

import { createSlice } from "redux-starter-kit";

const slice = createSlice({
  initialState: 0,
  reducers: {
    increment: (state, action) => state + 1,
    decrement: (state, action) => state - 1,
  },
});

export const { increment, decrement } = slice.actions;
export default slice.reducer;

Default-exports a reducer, named-exports action creators. It, uh, quacks like a duck to me :)

BTMPL commented 5 years ago

It's either that or every other module needs to be dependent on logout.

And personally I would go with the other one. It's up to the reducer to know that it should support resetting itself in response to the logout action, something that the "extra reducers" mechanism would solve (or just merging it all into one reducers field)

denisw commented 5 years ago

@markerikson, on the "ducks" point: sure, I mentioned that approach in my earlier comment. You improved on it with the destructuring export (👍), but the point stands that you still have to repeat the actions, which weakens the boilerplate-saving benefit.

Anyway, this and the logout action discussion are moving off-topic a bit. Of the points I raised, I think others deserve more focus:

markerikson commented 5 years ago

Swinging back around to this discussion. Thoughts:

So, at this point I'm set on removing it in the next minor release (yay pre-1.0 semver!).

Steering the discussion in another direction, I'm combing through the various issues and coming up with this list of other potential changes:

I am already planning to remove the slice selector. I am mostly good with changing slice to name and making it required, and with making the "slice" object be the reducer function itself.

Any further thoughts or suggestions on these other items, especially the async/effect related stuff?

RichiCoder1 commented 5 years ago

add some kind of "create async action" API that creates "start/success/failure" action triples and/or generates thunks that take a promise-returning function and dispatches those action types

An interesting prior art here that I've enjoyed using is redux promise middleware.

So something like this: (with some of the #109 ideas included)

const todos = createSlice({
    name: 'todos'
    initialState: [],
    reducers: {
       // this probably isn't actually correct but 🤷‍♂️
        setTodos: (_, { payload }) => payload,
        addTodo: (state, { payload: newTodo }) => state.push(newTodo),
        markCompleted: (state, { payload: index }) => state[index].completed = true,
    },
    effects: {
       // Dispatch and actions would probably be, from what I've seen of other state frameworks like Vuex, basically impossible to Type.
        syncTodos: async (state, action, { dispatch, actions }) => {
           const newTodos = await someSyncFunc(state, action.payload);
           dispatch(actions.setTodos(newTodos));
        }
    },
});

const ui = createSlice({
    name: 'ui',
    state: {
         loadingTodos: true,
         error: null
     },
     extraReducers: {
         // "todos/syncTodos/completed"
         [todos.actions.syncTodos.completed]: (state, { payload })=> {
             state.loadingTodos = false;
             if (payload.error) {
                 state.error = payload.error;
             }
         }
     }
});

Contrived, but does the general idea make sense?

phryneas commented 5 years ago

create actions that are tied to a slice by prefix, but not directly handled by a reducer

I don't know. For me, just using an empty function as a selector always worked pretty well. I don't think adding the complexity of another option would be worth to sometimes omit an empty function. (Although I get that some people are crazy about saving every last character).

add some kind of "create async action" API that creates "start/success/failure" action triples and/or generates thunks that take a promise-returning function and dispatches those action types

We already have the extended way of writing a reducer:

const testSlice = createSlice({
        slice: 'test',
        initialState: 0,
        reducers: {
          testReducer: {
            reducer,
            prepare: payload => ({ payload })
          }
        }
      })

What about extending that?

const testSlice = createSlice({
        slice: 'test',
        initialState: 0,
        reducers: {
          testReducer: {
            reducers: {
              start: /* ... */,
              success: /* ... */,
              failure: /* ... */,
            },
            prepare: payload => ({ payload })
          }
        }
      })
markerikson commented 5 years ago

Meh. I'm not sure how that's really any better than:

reducers: {
    testReducerStart() {}
    testReducerSuccess() {}
    testReducerFailure() {}
}

and just adds more complexity.

I'm inclined to punt on that idea for now.

neurosnap commented 5 years ago

As someone who has minimal influence on the overall API of redux-starter-kit, please forgive me while I inject my opinion again in this thread. I think that adding async actions to createSlice confuses the point of createSlice. To me, the purpose of createSlice is to automatically create actions and a reducer that are bound together to reduce the amount of boilerplate code required to set them up together.

To me, async actions can be just as easily created outside of createSlice without adding any boilerplate. By adding side-effects/async actions to createSlice we are slowly growing the scope of the function and it is morphing into a one-stop shop for building a feature.

Other apps already do this so one could argue we are slowly converging on an API:

However, I feel like if we want to go down that route then we should build on top of createSlice, not add to it.

I think the other suggestions for improvements make sense.

markerikson commented 5 years ago

Yeah, I've been glancing over at Rematch, Kea, and Easy-Peasy for points of comparison, and there's definitely similarities in how they handle declaring effects of some kind.

I'm not sold either way. I can see a point to having a place to declare thunks and listener callbacks. I can see a point to keeping createSlice minimal.

markerikson commented 5 years ago

I was this close to having createSlice return the reducer function itself, but eventually talked myself out of that idea (see #197 ).

That said, I do plan to include the generated reducers in the return object.

jamiewinder commented 5 years ago

Is there / should there be a way to read the root state inside a slice?

markerikson commented 5 years ago

@jamiewinder : no. See this Redux FAQ entry: https://redux.js.org/faq/reducers#how-do-i-share-state-between-two-reducers-do-i-have-to-use-combinereducers

markerikson commented 5 years ago

I think at this point the major design questions around createSlice have been resolved. We're not doing anything special with "combining slices", and the generated selectors are gone.

I'm still open to discussing the "side effects" aspect down the road, but that can be a separate point of discussion (per #76 , etc).