-
-
Notifications
You must be signed in to change notification settings - Fork 3.4k
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
RFC - API for dynamically adding reducers #1150
Comments
Thanks for filing this! We'll need some time to look this over and think about it, but I definitely appreciate you taking the time to put together a well-thought-out writeup. First question that comes to my mind: what would a non-HOC solution look like here, especially in a hooks-based world? Tagging @abettadapur and @mpeyper to get their thoughts, since I know both of them have done excellent work around Redux code splitting already. |
Of course! I think this would need a lot of time and testing to get right both design- and implementation-wise. I know a lot of things are in flux (pun intended) and uncertain right now so might not be the best of times to design and commit to maintaining a new public API but wanted to get the discussion going. :) This proposal doesn’t necessarily contain a HOC at all, the basis is just a component. A HOC can be easily added on top of that. It’s a weird case where the I’ve been thinking about hooks too but didn’t want to complicate matters in the first draft. :) The thing to note about hooks is that since a hook can’t Provide context, a hook-only solution would have to return a component that the user wraps a subtree in manually. Definitely doable. |
A thing to note about the current design is that I explicitly tried to stick to what’s already in React and also make it possible to backport to <6 and legacy context if desireable. It’s probably not, but anyway.. 🤷♂️ |
I would love to see first class support for something like this personally. We have a lot of "feature" components tied to certain routes and when the user navigates away, it would frankly be nice to just clean this up vs having a bunch of clear state/cleanup like actions. |
I suppose a related question is, how much of this should be handled at the Redux store level, vs the React-Redux level? |
That is a good question. I think Redux If the goal and scope is broadened to provide a better/simpler high level story around code splitting/dynamic reducer injection in general, I think there is an opportunity to add functionality directly to Redux as well. That goal would basically be to remove some of the ceremony documented in the code splitting recipe I guess? In terms of this RFC, I think what such a change to Redux would mean is that there would be a default implementation of Either way, I do think providing low level primitives is important to retain the flexibility Redux is known for. 😄 This is all just my quite limited view on it though, did you have any specific thoughts in mind? |
I updated the RFC above with an extra alternative solution: Add same functionality directly to existing connect-HOC |
Conceptually, I find this very interesting as this is essentially what From an implementation perspective, I think there are a few things you may want to consider, especially if you want to shoot for improving the overall dynamic reducer story. Take this snippet from the sandbox, for example: let { reducers } = App;
function getRootReducer(newReducers) {
reducers = { ...reducers, ...newReducers };
return combineReducers(reducers);
}
const store = createStore(combineReducers(reducers)); There's quite a bit going on here, and a lot for someone coming into this project to need to understand:
Now, arguably, some of these are easy to answer, but the point is that they need answering, and it may be setup or handled differently between different projects, which adds cognitive overhead for any devs diving into the codebase. FWIW, An advantage of your proposed version over this is it allows users to modify how the reducers are stored and merged, allowing for more customisation (e.g. Another thing I noticed in constructor(props, context) {
super(props, context);
const rootReducer = context.getRootReducer(props.reducers);
context.store.replaceReducer(rootReducer);
const firstRenderState = rootReducer(context.storeState, {}); // <----- this line
this.firstRenderContextValue = {
...context,
storeState: firstRenderState
};
} Is there any reason that line could not just be Finally, I'd caution you about pushing the logic to the root of the app. In my experience, which is admittedly a bit niche in the Now, my 2 cents on your questions:
I agree that the
If every single export default props => (
<WithReducers reducers={redcuers}>
<ConnectedComponent {...props} />
</WithReducers>
); Then this is a good use case for a HOC. I'm personally a fan of including the functionality in the
By including it in the
TBH, this is the main reason why I think this whole thing is best kept in userland. The redux ecosystem is huge, varied and ever-changing. You will never support all of it, especially when targeting the dynamic use cases. You could reasonably say that reducers are the only required part of redux so dynamic reducers the only one that is supported out-of-the-box (to which I would counter argument that it already is by
Yes. This was one of the first questions/requests we always got with What would I do?
This. It's part of the private API already, but so widely used (at least the legacy version), that it's going to be very difficult to ignore it moving forward anyway. If you really must hide it, then a Oh man, I went overboard with that sandbox, but I do really enjoy this problem! The actual semantics of how In the process of setting that sandbox up, I discovered that you cannot call Also, I know I mentioned about about avoiding extra component layers and I'm aware this implementation does not fare well in that regard either. Don't judge me too harshly. I hope some of this helps, and I'm always happy to discuss this more. |
@mpeyper Wow, that's a deep and thoughtful comment, thank you so much for writing it! 💯 Edit: This turned out to become a lot longer than I intended, sorry for the wall of text. Turns out this is really hard to write about concisely.. 😅
This RFC was heavily influenced and motivated by prior work like that, I've looked at
This is at the heart of the problem. In a concurrent world, another action might have fired between the start of rendering when High level vs low level, opinionated vs unopinionatedJust to be clear, this RFC mainly aim to be low level and unopinionated, providing a better integration-point to build solutions in userland on top of.
I agree with almost everything you are saying here (and you said it well), except one thing. While I do think that If we can simultaneously improve the built-in experience for simple cases, that would be a great win, but is not the main point of this RFC to be honest.
This was exactly the vision that I had in mind when writing the RFC, I explicitly tried ot make it so low level and unopinionated that libraries like
I haven't had time to dig into this very deeply so I might very well be wrong, but I suspect this related to why you are having problems with SSR in I definitely agree that you might sometimes need some more of the context of the call site though, definitely room for improvement in the RFC there. 👍 Feedback on RFCThank you for the specific feedback on the RFC, I agree with most of your comments. :) HOC should be included, remove on unmount as well.
Maybe: Even though this won't be as common as using What would I do & StoreInterceptorI agree that accessing the store from context is not going away anytime soon, if ever, even if it's unofficial.
I'm pretty sure I've seen @markerikson write about/discuss this several times in different places, maybe you could fill in what I have missed or link to previous discussions? When I started thinking about this RFC I also tried to come up with ways to have an even more generic low-level API, but that still avoided most footguns. In my head
That makes two of us! 😉 I really like your implementation when considering a client-side application in the current state of React. When considering concurrency and SSR I see a couple of issues though:
This is suuuper-hard and has tripped me up soo many times already, I'm pretty certain I still have similar bugs in my demo-implementation and even if not, before concurrent rendering is finished and final, I'm certain I will. These complexities is exactly why I feel there is a need for a better integration-point in React-Redux for adding reducers inside of the component tree (that is, provide an easy way to patch the ReactReduxContext safely in these cases). A downside to this is of course that it would lead to an increased maintainer-burden in this repo instead of in userland and any bugs would probably have an even higher impact in the community. On the other hand they would hopefully get fixed faster and only have to be fixed once. If any problems arise directly from the design of such a solution and not its implementation, that would be really bad though, which is why this RFC was so scary to write.. I'm far from certain my RFC tackles this the best way, my only hope is that it describes the problem-space decently.
It really did! I'll incorporate a bunch of this next time I revise the RFC! ⭐️ |
@mpeyper If you could dream, what would a ergonomic and flexible API to implement How much of the current |
I author We export a We then have a higher order component Because 'modules' can contain any kind of redux artifacts (reducers, middleware, epics, sagas), a single |
Great comment @abettadapur, thank you, and thank you for your work on I'm definitely not proposing we remove access to In this RFC you might choose to do other things to I have not yet thought much about what sideeffects dynamically adding middlewares, epics or sagas could have in a concurrent world and also don't have any experience in adding them dynamically. Not adding specific support for them in the RFC stems both from wanting to keep scope down, and inexperience and lack of knowledge on my part, suggestions in this area are very welcome! In what phase are these typically injected? In a concurrent world I guess it would make sense to inject them in the commit-phase (since they are probably not needed until an action is fired which I guess would happen at a later point)? If they were injected in the render-phase, this could lead to actions from a different fiber going through middleware/sagas/epics that were added in a work-in-progress fiber that has not yet been committed? Maybe this is not a huge problem? The tricky part about reducers specifically is that they need to be injected before rendering and the state change from this be made available outside of the @abettadapur @mpeyper In your eyes, how could this RFC be improved to better support dynamically injecting other things than reducers? |
@Ephem Thanks for the detailed reply. All your points make sense from an SSR perspective and wanting to support the common case. Your point on why
I'm not sure how the I have often admitted that what I work on (and how IOOF in uses That said:
It sounds more complicated than it actually is, but the point is that this RFC would assist a single store enhancer ( To be clear, if I could replace all my libraries with build in It's a bit hard to say what my dream API would look like because if I knew that I would have raised an issue or a PR a long time ago, but I think
I don't have any suggestion for improvements, just observations from me experiences dealing with the redux ecosystem and strange use cases. In general, anything other that reducers is going to concern itself with either generating or listening to actions. My knowledge of SSR is admittedly a bit weak, but I think this means that dynamically adding them in an SSR scenario does not make much sense or at least, as you suggested, would have no effect on the returned output. If we restrict the conversation to the "big ones", Looking passed the naming issues, this means that your suggestion of using the let { reducers, sagas, epics } = App;
const sagaMiddleware = createSagaMiddleware();
const epicMiddleware = createEpicMiddleware();
function getRootReducer(newProps) {
reducers = { ...reducers, ...newProps.reducers };
newProps.sagas.forEach(sagaMiddleware.run);
newProps.epics.forEach(epicMiddleware.run);
return combineReducers(reducers);
}
const store = createStore(combineReducers(reducers), applyMiddleware(sagaMiddleware, epicMiddleware));
sagas.forEach(sagaMiddleware.run);
epics.forEach(epicMiddleware.run); export default props => (
<WithReducers reducers={{ reducers: newReducers, sagas: newSagas, epics: newEpics}}>
<ConnectedGreeter {...props} />
</WithReducers>
); One issue this has is that these middleware don't care if if you call them with the same saga or epic multiple times, so some care may need to be taken to prevent this on mounting multiple instances conditionally rendered components. If let { reducers, sagas, epics } = App;
const sagaMiddleware = createSagaMiddleware()
const epicMiddleware = createEpicMiddleware()
const runningSagas = Object.keys(sagas)
const runningEpics = Object.keys(epics)
function getRootReducer(newProps) {
reducers = { ...reducers, ...newProps.reducers };
const newSagaKeys = Object.keys(newProps.sagas).filter(key => !runningSagas.includes(key))
const newEpicKeys = Object.keys(newProps.epics).filter(key => !runningEpics.includes(key))
newSagaKeys.forEach(key => sagaMiddleware.run(newProps.sagas[key]);
newEpicKeys.forEach(key => epicMiddleware.run(newProps.epics[key]);
return combineReducers(reducers);
}
const store = createStore(combineReducers(reducers), applyMiddleware(sagaMiddleware, epicMiddleware));
sagas.forEach(sagaMiddleware.run)
epics.forEach(epicMiddleware.run) There are likely issues with this as well, such as running the sagas/epics before the My biggest concern is with each middleware the complexity and understanding required of what I'm also not sure how this would look if we start thinking about library authors providing opinionated variations of
Yep... I've got this problem too 😛 |
It was not, so sorry for confusing you.. :( I just realized I never implemented the wrapper I was going to, my sandbox is updated. The main difference between the solutions is that since yours is based on importing the A simplified SSR usage-example for that, borrowing from your solution, could look like this: // Express endpoint
app.get('/', (req, res) => {
const store = createStore(topReducer);
const reducerRegistry = createReducerRegistry();
const html = ReactDOMServer.render(
<Provider store={store}>
<ReducerRegistryContext.Provider value={reducerRegistry}>
<App />
</ReducerRegistryContext.Provider>
</Provider>
);
res.render('index', { html });
}); Then you would pick up The rest of your comment around sagas and epics was illuminating, I'll mull it over, research some more and get back with a more detailed comment on that topic. Just to clarify though, the |
I'm playing with the idea that maybe const reducers = { ...staticReducers };
function modifyStore(replaceReducer, options) {
reducers = { ...reducers, ...options.reducers };
// Put stuff to modify the store before replacing reducers here
replaceReducer(combineReducers(reducers));
// Put stuff to modify the store after replacing reducers here
}
ReactDOM.render(
<Provider store={store} storeModifier={modifyStore}>
<App />
</Provider>
); The
Variant Pass Either approach should make it a bit more flexible to do other stuff than just replacing the root reducer, and also make it clear that this is intended. The BTW, the only guarantee React-Redux makes about the I haven't thought this through, just tossing the idea out there for early feedback. 😄 |
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 Setting it up might look something like (stolen heavily from let modifyStore = () => {
throw new Error('not yet!');
}
const chain = modifiers.map(modifier => modifier({ ...store, modifyStore }));
modifyStore = compose(...chain)(() => {}); // `modifyStore` without any modifiers does nothing. Then you would invoke it using the options provided from modifyStore(options); Now we can split the modifiers into nice pieces, and even have modifiers call other modifiers. e.g: const reducers = { ...staticReducers };
const dynamicReducers = store => next => options => {
reducers = { ...reducers, ...options.reducers };
store.replaceReducer(combineReducers(reducers));
next(options);
}
const sagas = { ...staticSagas };
const dynamicSagas = (sagaMiddleware) => () => next => options => {
const newSagaKeys = Object.keys(options.sagas).filter(key => ! sagas.includes(key));
Object.keys(options.sagas)
.filter(key => !runningSagas.includes(key))
.forEach(key => {
const saga = options.sagas[key];
sagaMiddleware.run(saga);
sagas[key] = saga;
});
next(options);
}
const dynamicModules = ({ addModules }) => next => options => {
addModules(options.modules); // I have no idea if redux-dynamic-module handles adding the same module multiple times, but this is just for demonstration purposes
next(options);
}
// dumb example
const conditionalReducers = (predicate, reducers) => store => next => options => {
if (predicate(store)) {
store.modifyStore({ reducers });
}
next(options);
}
const storeModifiers = [
dynamicReducers,
dynamicSagas(sagaMiddleware),
dynamicModules,
conditionalReducers(({ getState }) => getState().user.isAdmin, { adminSettings })
];
ReactDOM.render(
<Provider store={store} storeModifiers={storeModifiers}>
<App />
</Provider>
); 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 I find the EDITI just realised that this suggestion is starting to look a lot like |
I totally agree with you that 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? |
@Ephem @mpeyper This is essentially what we do with For example, the saga extension (in typescript): /**
* Get an extension that integrates saga with the store
* @param sagaContext The context to provide to the saga
*/
export function getSagaExtension<C>(sagaContext?: C, onError?: (error: Error) => void): IExtension {
let sagaMonitor = undefined;
//@ts-ignore
if (process.env.NODE_ENV === "development") {
sagaMonitor = window["__SAGA_MONITOR_EXTENSION__"] || undefined;
}
// Setup the saga middleware
let sagaMiddleware: SagaMiddleware<C> = createSagaMiddleware<any>({
context: sagaContext,
sagaMonitor,
onError,
});
let _sagaManager: IItemManager<ISagaRegistration<any>> = getRefCountedManager(getSagaManager(sagaMiddleware), sagaEquals);
return {
middleware: [sagaMiddleware],
// perform initialization here
onModuleManagerCreated: (moduleManager: IModuleManager) => {
if (sagaContext) {
sagaContext["moduleManager"] = moduleManager;
}
},
// perform behavior when module is added
onModuleAdded: (module: ISagaModule<any>): void => {
if (module.sagas) {
_sagaManager.add(module.sagas);
}
},
// perform behavior when module is removed
onModuleRemoved: (module: ISagaModule<any>): void => {
if (module.sagas) {
_sagaManager.remove(module.sagas);
}
},
dispose: () => {
_sagaManager.dispose();
},
};
} I think the built in concept of a 'store modifier' is a great idea. Libraries like saga/observable can then leverage the API and publish their own 'modifiers', making it easy for users to consume this concept. Maybe I missed it in the text above, but one question: where is the 'modifier' chain called from? The |
Thats a great example, thank you! I should probably take a closer look at the src for I've rewritten the RFC and is currently working on a demo implementation, so hopefully I'll have a PR up soon. I think that RFC is a bit clearer on what you are asking and with some (desperately needed) better naming as well. Having a PR will hopefully also make it easier to discuss different details. In this current RFC, it is the In the new RFC this is currently: |
I'm going to go ahead and close this and the related PR, as there are a lot of more important things to tackle short-term (as per this roadmap). While I still think a public API for injecting reducers from the component tree is needed, we should give other things some time to stabilise before revisiting the details around this. The roadmap suggests contributing tests for these cases, and while they will necessarily test private APIs and therefor be somewhat brittle, I agree that this is the best way forward right now. |
See 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:getState()
<Provider>
callsgetState()
, state gets passed along via context and does not change during a single renderThis 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 callingreplaceReducer
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:
store
orReactReduxContext
in userlandreact-redux
v6 approach for preparing for concurrent-safenessExample usage
(Some imports etc omitted for brevity)
API
Pretty much what you see in the example above, with the addition that
<WithReducers>
also takes the boolean propreplaceExisting
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
getRootReducer
really a good name considering it’s basically expected to have sideeffects? ShouldWithReducer
be called something else? Etc..)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.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.
The text was updated successfully, but these errors were encountered: