RFC - API for dynamically adding reducers
See original GitHub issueSee discussion in issue #1126 for context.
Problem: Because ReactReduxContext
is not part of the public API, there is currently no way using only public APIs to safely support dynamic loading of reducers. Even using this, implementation is tricky and error prone and there is also room to create a better story around code splitting reducers.
Proposed solution: User provides a getRootReducer(newReducers)
-function to <Provider>
. New component <WithReducers reducers={newReducers}>
can be used to wrap a subtree that needs these reducers.
Summary of problem (recap of #1126)
react-redux
version 6 now uses the new context instead of the legacy one, and instead of every connected component being subscribed directly to the store, only the root <Provider>
is. That is:
- Before: Every connected component called
getState()
- After: Only
<Provider>
callsgetState()
, state gets passed along via context and does not change during a single render
This is done to avoid “tearing” in concurrent rendering mode (avoid different parts of application rendering with different state).
In a world with concurrent rendering, replaceReducer
is trickier to use from within the render tree. This is because a call to this function modifies the state in the store and subsequent components expect this state to be available, but now no longer fetches the latest state automatically, but instead from context.
On top of fetching store
from the context and calling replaceReducer
on it, the solution from #1126 is to patch the current storeState from context with the state added by the new reducers by wrapping that subtree in a new <ReactReduxContext.Provider>
. However, ReactReduxContext
is not part of the public API.
In a sense this is not a problem new to v6, since several of the old solutions for code splitting reducers in React relied on fetching store
directly from context, which was never part of the public API.
On top of this, I feel like code splitting is something to be encouraged and there is room to improve the story around this and that this aligns with the goals of react-redux
.
Proposed solution
Please note that this is just a rough sketch of what a solution could look like. I’d love feedback and to get a discussion going around both large issues and small.
Rough implementation and example usage in CodeSandbox - This is forked from @rgrove’s excellent repro-case from #1126
My hope is that this solution strikes a balance between being immediately useful and preserving the flexibility for the user to construct a root-reducer and injection techniques however they feel like.
Goals:
- Add API for adding reducers from the component tree
- Do not require direct use of
store
orReactReduxContext
in userland - Flexible API that supports both simple and complex usecases well and can be built on top of
- Small surface area
- Aligned with
react-redux
v6 approach for preparing for concurrent-safeness
Example usage
(Some imports etc omitted for brevity)
import { WithReducers } from 'react-redux';
import reducer from './namesReducer';
function Greeter({ currentName }) {
return `Hi ${currentName}!`;
}
const mapStateToProps = state => ({
currentName: state.names.currentName
});
const ConnectedGreeter = connect(mapStateToProps)(Greeter);
const newReducers = {
names: reducer
};
export default props => (
<WithReducers reducers={newReducers}>
<ConnectedGreeter {...props} />
</WithReducers>
);
import staticReducers from './staticReducers';
// Example simple userland implementation
let reducers = { ...staticReducers };
function getRootReducer(newReducers) {
reducers = { ...reducers, ...newReducers };
return combineReducers(reducers);
}
const DynamicGreeter = React.lazy(() => import('./Greeter'));
ReactDOM.render(
<Provider store={store} getRootReducer={getRootReducer}>
<Suspense fallback={<div>Loading...</div>}>
<DynamicGreeter />
</Suspense>
</Provider>
API
Pretty much what you see in the example above, with the addition that <WithReducers>
also takes the boolean prop replaceExisting
which determines if existing reducers/state gets overwritten. Defaults to false.
Could also add other things, such as optionally removing reducers/state on unmount.
Unanswered questions
- Naming (Is
getRootReducer
really a good name considering it’s basically expected to have sideeffects? ShouldWithReducer
be called something else? Etc…) - When we have a component like this, it’s easy to add a HOC based on it, should this be part of the official implementation? Component version leaves forwardRef and hoisting statics to userland, HOC could do this behind the scenes.
- This solution does not take into account other sideeffects such as adding sagas etc. Might be possible to add those in userland in
getRootReducer
since you often have access to store there, but then it should be renamed. Not sure if it’s in scope for this RFC. - Optionally removing reducers on unmount?
Probably a lot of questions I haven’t thought about, please feel free to add more!
Alternative solutions
Status quo - Determine that it is fine that these solutions rely on non-public APIs
Not desirable. Code splitting should not break between versions.
Make ReactReduxContext part of public API - Leave implementation to userland
This is a perfectly valid way to go. It does leave a lot of complexity to userland though and future updates to React & Redux might very well break implementations unintentionally. Using it directly is often a footgun, so should be discouraged.
Add escape hatch for tearing - As proposed in #1126
This was a proposal to opt-in to the old behaviour (components fetching directly from store) for first render via a prop on <Provider>
, I feel this is the wrong way to go since it doesn’t tackle the problem directly and is probably a footgun. Was abandoned in #1126.
Add same functionality directly to existing connect-HOC - Add a new option to connect
Probably a viable alternative. Kind of a connect and make sure these extra reducers are present. Could either update the storeState
on context so all following connects don’t have to add it explicitly, or require every connect that depends on these reducers to add them explicitly. This would only apply to state, either way the reducers would be added to the store on the first connect.
Different API from the proposed one - Based around same solution
If you have better ideas for how the public API for this could look like, I’d love it! The proposed solution is a rough one to get discussions going.
Something else? - There might very well be solutions I have not thought about.
This is by no means a finished or polished suggestion, I’d love to get a discussion going to improve it! Would it solve your usecase? It is a good fit to include in react-redux
? Etc…
I’d be happy to submit this in PR-form instead if that would be helpful, but wanted to start as an issue to encourage discussion.
Issue Analytics
- State:
- Created 5 years ago
- Reactions:9
- Comments:20 (17 by maintainers)
Top GitHub Comments
I totally agree with you that
With...
as a component name is jarring, I never liked it and added it mostly for clarity in the RFC around what the component is supposed to do. Now that we’ve come this far, I would suggest that the top function would be calledmodifyStore
(verb), the component be calledStoreModifier
(noun) and the HOCwithModifiedStore
.I really like the idea of a chain! Only problem that would need to be solved is clashing options I guess. What happens if you add several modifiers that unknowingly uses the same option-keys? I’m sure there would be a number of ways to solve that by namespacing though.
I also agree that passing the store, or a (possibly stripped down?) StoreAPI is the better option.
That EDIT is exactly where I was hoping to bring this RFC! Making it possible and hopefully easier to implement both existing and new solutions on top of.
I’m starting to have a good idea what an updated RFC would look like, but I don’t want to edit the original comment since that would cause confusion for new readers. I think at this point (after initial feedback) it makes sense to instead open a PR with the updated RFC (and possibly an example implementation) to help further discussion and so we can track changes.
I might leave the chain out of the original PR just to keep it simple at first, in that case with the intention of adding it. I’ll write it up ASAP.
Thank you so much for your comments @mpeyper and @abettadapur! Do you have any thoughts on this new approach @abettadapur?
I like the look of this new approach.
With a little bit of work, the store modifier could be made into a modifier chain (similar to redux’s middleware chain) that would allow more modular store modifications to occur. I think passing through the whole store (or a
storeAPI
?) would make the chain concept a bit nicer too (especially for libraries that enhance the store with additional functions, such as redux-dynostore and redux-dynamic-modules).Setting it up might look something like (stolen heavily from
applyMiddleware
):Then you would invoke it using the options provided from
WithModifiedStore
:Now we can split the modifiers into nice pieces, and even have modifiers call other modifiers. e.g:
Like middleware, the order in which the are provided would determine the order they are invoked (e.g. to ensure the reducers get added before the sagas are run). I’ve probably got the order wrong above, but 🤷♂️.
NOTE: this was all written in github editor and not tested. I’m always a little iffy when trying to use things like
compose
so there may need to be some tweeks and before it works.I find the
WithModifiedStore
(andWith...
as a component name in general) a bit jarring as the thing it’s providing the modified store to might be a deeply nested child or may not even exist at all if it’s just running a saga. I think a small change toModifyStore
would be better (open to other ideas too. Making it an option in theconnect
HOC also fixes this, but is less flexible than having an intermediate component, which I’m coming around to as being the better idea as otherwise we may start to see components being connected just to add store modifications (i.e. not mapping any state or dispatches to use).EDIT
I just realised that this suggestion is starting to look a lot like
redux-dynostore
’s enhancers. I could envision@redux-dynostore/core
(the thing that adds all the extra functions to the store) not changing at all and@redux-dynostore/react-redux
exporting a bunch of modifiers to use withProvider
anddynamic
just being a thin wrapper around theWithModifiedStore
component, ensuring the provided things went into the correct keys of the options for the modifier to pick up.