-
-
Notifications
You must be signed in to change notification settings - Fork 15.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Proof of Concept: Enhancer Overhaul #1702
Conversation
Note that these benefits above are based on my gut feeling. I’ll still need to verify all these problems are actually solved by this abstraction. |
On first glance this seems really great — I like the separation of dispatch logic and subscription logic. But I don't understand this sentence:
Why wouldn't one of today's enhancers continue to work the old way with this new |
Interesting. So, at it's core, this can be thought of similarly to rearranging a traditional class inheritance. That is, rather than the enhancer extending the Redux "class", Redux inherits from something that implements a particular interface. (Not sure if that analogy is helpful, but it's what springs to mind at the moment). Or another way to think of it: Inversion of Control (IoC). So, this isn't unprecedented. |
// reducer returns their initial state. This effectively populates | ||
// the initial state tree. | ||
dispatch({ type: ActionTypes.INIT }) | ||
replaceReducer(reducer) | ||
|
||
return { | ||
dispatch, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we have the "real" store extend the store base here via spread, so that enhancers can add their own properties besides dispatch
and getState
?
return {
...storeBase,
dispatch,
getState,
subscribe,
// ... and so on
}
E.g. this would allow the observable interface to be safely be implemented as an enhancer. Not that we'd necessarily want to do that — just an example of how alternative event systems* are possible with this new enhancer architecture.
*not that they weren't possible before, but now they're safer
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In other words, I don't see the benefit of "sealing" the enhancers, and it seems like you lose some extensibility as well.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The problem I tried to solve is that anything other than { getState, dispatch }
is hard to compose, and enhancers have no knowledge of the past enhancers. If any enhancer could add a method like subscribe()
, it would be a pain to later batch subscriptions in any later enhancer (this is pretty much why https://github.com/tappleby/redux-batched-subscribe is currently using a less performant subscription mechanism which was copy pasted from earlier versions of Redux). If any enhancer could add [$$observable]
, then any further enhancer changing getState
would cause it to report incorrect values because the inner enhancer uses the inner getState
.
Sealing off limits the domain of enhancers to:
- Hijacking
reducer
- Hijacking
initialState
- Hijacking
dispatch
- Hijacking
getState
- Hijacking
onChange
All these things are guaranteed to be orthogonal: it is always possible to write an enhancer that changes some of them and it is guaranteed that this won’t trip up enhancers before or after it.
However as soon as we let enhancers define non-orthogonal methods (such as [$$observable]
which depends on subscribe
and getState
), this introduces subtle failures. If a default enhancer included subscribe
, a thing that wants to delay subscribe
would have to reimplement its logic.
I feel good that all existing use cases seem covered by (reducer, initialState, onChange) => { dispatch, getState }
. I would be wary of making the system more expressive because this caused issues in the past. Most enhancers so far had to work around this freedom rather than enjoy it. Finally, for what it’s worth, it is always possible to really wrap createStore
if you really need it. I’m just not convinced this is any close to being a common case for the enhancers I have seen.
The more I think about this the more I really like this, Dan. I hope we can think of a good name for "store base." In my view, a "store base" is really the meat of Redux, whereas what we now think of as a "store" is a store base plus some subscription stuff that really isn't all that important (other than as an an integration point for projects like React Redux), plus some extra opinions (actions should be serializable, automatic INIT actions) that are advisable for the majority of people, but in the grand scheme of things aren't super crucial either. |
So, effectively, Redux comes with a default enhancer that adds the |
@jokeyrhyme Yeah pretty much |
@jokeyrhyme Actually, the way I'd model it in my head is
where the store base handles state updates and |
I can somewhat understand where the idea about sealing off the base comes from, but I think that this is a bit too opinionated. If someone wants to extend the store with custom methods, they should be able to do so. It might be useful to put a big warning there if you do that, but just disallowing out of the box seems like a harsh change. |
We have It might be nice for performance, for example, to only attach the dev-tools enhancer while the dev-tools panel is visible. Or to be able to instantiate and attach a history enhancer after the creation of the store. |
I think this is far better than the actual way. Dispatching the INIT action after store creation 'and' using the enhanced dispatch would solve a bunch of startup issues like #1240 or other similar issues. A small remark on the For example, to resolve issues like #1240 the middleware should wait first for the INIT action before dispatching its first action. |
Like the concept a lot. Having The transition for Chrome extension would be a bit difficult as it is updated automatically, but keeping also old |
Today’s enhancers would work. However we can throw inside In other words, today’s 3.x enhancers technically can work on 4.x stores, but as people will ask the authors to make them passable as third arguments, we’ll end up with 4.x-only enhancers that can only be applied as an argument because they only return |
If we were to draw a class analogy, then |
They are able to, but since this breaks composition of enhancers, they would do it outside our API. store.myCustomWhatever = ... It just doesn’t need we need to bless this anymore because we found the sweet extension point that solves most of the use cases I have seen in the wild with enhancers. |
I think we wouldn’t be able to add this because there is no guarantee that the existing state generated by one enhancer tree would be compatible with the next enhancer tree. (Indeed, in the case of switching DevTools off, it wouldn’t be.) At this point you might as well create the store again directly. |
Yeah, I haven’t thought much about this yet. With this model we’re in a much better place to enforce constraints (e.g. we can throw if the middleware failed to synchronously pass |
I definitely like this change, looks like a clear separation of concerns. Tried to think about it in all my enhancers and didn't come with any potential issue. |
By the way this gives us an opportunity to finally pass the |
Copying part of my comment above here in case it gets hidden by a new commit:
Another way of putting this is that I think "store base" is a better way for userland to experiment with alternative (perhaps higher-level) APIs, rather than composing |
function replaceReducer(nextReducer) { | ||
if (typeof nextReducer !== 'function') { | ||
throw new Error('Expected the nextReducer to be a function.') | ||
} | ||
|
||
currentReducer = nextReducer | ||
var nextInitialState = storeBase ? getState() : initialState | ||
storeBase = createFinalStoreBase(nextReducer, nextInitialState, onChange) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hey, now we can throw here if the enhancer returns the wrong thing! Sweet
function replaceReducer(nextReducer) { | ||
if (typeof nextReducer !== 'function') { | ||
throw new Error('Expected the nextReducer to be a function.') | ||
} | ||
|
||
currentReducer = nextReducer | ||
var nextInitialState = storeBase ? getState() : initialState |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With this, initialState
will never be garbage collected. You'd either need to move the initial storeBase
creation outside of replaceReducer
, or null it out after this.
So I started cataloging store enhancers with the help of @markerikson. I've got this google spreadsheet in progress. I briefly looked at the code of all of these, to get a feel for what they're doing and how. I think this PR would make a lot of these enhancers very unhappy. There's a lot of enhancers that add their own properties to the store, or do interesting things with the subscribe mechanics, as examples. Or a lot of them just subscribe. Taking those abilities away means they'll have to forego being a store enhancer and instead do their business outside of createStore, pushing the setup burden into the application code. |
Wouldn’t this still be possible with custom |
Could I ask you to pick a minimal enhancer example that seems inconvertible to the new approach? Apart from custom methods/properties (IMO it’s a bad pattern and in this case it’s fine to force users to wrap, since otherwise they’d be looking for those methods/properties in Redux docs). |
I wasn't sure whether to start a new issue or comment here, but I think what I am struggling with is not possible in the current design of Redux and would need to be added as a 4.0 change. Essentially it is currently not possible to have middlewares emit actions that need to be processed by enhancers higher than them in the chain. The idea of a middleware emitting something that needs pre-processing before hitting the reducers appears to be accepted as a good thing because the middleware enhancer ensures that they are composed in a way that lifts all new actions to the top of the middleware chain. Giving enhancers access to the top-of-chain dispatch method ensures that new actions emitted by other enhancers are run through the full enhancer/middleware chain. For middleware a distinction is made between One additional advantage of this is that middleware no longer needs to be special, and is in fact just another enhancer. |
@Parakleta : I think the difference is that middleware can dispatch back to the start of the middleware pipeline. So, nothing is going outside the bounds of In contrast, enhancers are intended to be layered on top of each other without knowing what's before or after. Can you give an example use case for when something like "going back to the outermost enhancer" might be necessary? |
Essentially any time a middleware (or by extension an enhancer) emits a new action (in my case particularly async action emitters) I think those should be subject to the same enhancements as new actions emitted from other sources. The absence of this ability is what has caused a range of issues to be raised regarding incompatibilities with the router enhancer and other middlewares, makes batching difficult, and I suspect is going to cause problems when The issue is raised in #1051, #1240, and #1294 but kind of left unresolved. Related are all of the issues in other projects that cross-link with those issues. Quite a few of those external projects seem to be hoping that this PR will fix the problem for them. In the very simplest case if I have a batching enhancer and some middleware if I compose them
I don't know. For every use case that I can probably come up with there is probably a work around anyway, but the crux of the issue is I think that the only dispatch available to enhancers is not actually |
This has been sitting open forever, and I think we're unlikely to do anything about it in the near future. The PR exists, we can always refer back to it for discussion later. Maybe something to consider for a notional Redux v5 or something. |
Heavily inspired by #1576 but taken further.
(I temporarily removed JSDoc because everything inside moved.)
The public API for regular consumers stays the same. The public API for people who write store enhancers is simplified.
We add a new concept. I tentatively call it “store base” but its name doesn’t really matter yet. I encourage you to avoid bikeshedding on the name for now, and consider the concept instead.
The “store base” thing is exactly what we currently pass to the middleware. It’s a stripped down version of the store that offers no subscriptions. It’s just
{ dispatch, getState }
. In this PR, we consider Redux if it was implemented around this abstraction.Since a “store base” doesn’t have a subscription mechanism, a function that creates the store base accepts
(reducer, initialState, onChange)
as arguments. The mechanism of notifying subscribers is implemented at a higher level by the store.Making
onChange
an argument is important for keeping the “store base” a useful composition abstraction. For example, a “store base” enhancer may want to wraponChange
to debounce calls to all store subscribers without knowing anything about the subscriber logic.I might be wrong but I have a feeling that “store base” enhancers can completely replace store enhancers as the main Redux extension mechanism. Not also do they offer wrapping
reducer
andinitialState
, but they also have a number of other benefits that store enhancers don’t:getState
anddispatch
.replaceReducer
or copy-pasting our$$observable
implementation since both of those things are implemented at the store level which “seals” the composed store base enhancers.onChange
as they see fit without knowing what’s inside of it.INIT
action goes through all enhancers and middleware because it is emitted outside the “store base” chain in the store itself. The store has the ability to enforce that the initial action is dispatched synchronously, as it can check the current state right after the first dispatch. (Middleware not dispatched through on init #465)subscribe
method.This doesn’t even quite introduce a new concept, as we already pass a thing with the same API to middleware. We’re just giving it a name (rather than a vague “middleware API”) and using more prominently. Also, it is only relevant to people who write store enhancers.
The only downside I can think of so far is the documentation / tutorial update / ecosystem costs, but luckily we never documented enhancers thoroughly, and we only have a few popular ones. In fact many some enhancers should just work unless they forget to pass all arguments, add custom methods, or specifically handle
subscribe
(in which case they’d be better off handlingonChange
instead anyway). And we don’t need to be stuck in an update limbo because we can release a 4.0 alpha that switchesenhancer
argument to mean a “store base” enhancer, and send some PRs to popular enhancers, and then roll it out when everybody is happy.Thoughts? I know this is a big change, but we barely had any breaking changes since the release, and if there are no other problems, I feel this moves Redux closer to what I originally tried to pull off with separating dispatching from subscribing when I was first thinking about Redux. I’d be much more comfortable with this store extension mechanism than what we have now, but if I’m wrong please help me understand why.
@acdlite @zalmoxisus @tappleby @tomkis1 @yelouafi @timdorr @lukewestby