redux-dynamic-modules
redux-dynamic-modules copied to clipboard
Strategies for hydrating a store from SSR
Hi there, I'm experimenting with this package and have been pleasantly surprised so far. Good job!
I wonder if it would be worthwhile to add some documentation about possible solutions to deal with server-side rendering. A typical server-side rendered Redux setup looks like this:
- On each request to the server, a fresh Redux store is created for that request.
- When generating the response body, the Redux state for that request is serialized into the HTML.
- When the client hydrates, a new global client-side Redux store is created and preloaded with the serialized state.
Maybe you can see the problem: on the server, some arbitrary set of dynamic Redux modules may have been loaded. On the client, we get the state those modules created, but we have a new store instance without any modules actually loaded. Besides inferring it from what state exists in the store, there's no indication of which modules need to be loaded to deal with that state. The set of loaded modules is effectively part of the Redux store's state, but one that hasn't been serialized from server to client.
You might think DynamicModuleLoader
is a good enough solution for this, since it should result in the same modules being loaded on client and server if the same tree is rendered (like it should). The issue is that often, data needs to be fetched ahead of time (e.g. with Redux) before a component tree is even created/decided upon, since (until Suspense arrives) there is no way to "wait" for data during SSR. So, frameworks invent new lifecycle hooks to do this fetching, like getInitialProps
in Next.js. In this hook, you'd need to use Redux, fire actions, etc., but any module you'd need would have to be added imperatively with addModules
.
The set of loaded modules must be communicated in a serializable way, like an array of strings. This could be the module IDs (you'd need to keep a global mapping of ID → module in order to actually load them, which might defeat the purpose of splitting everything up in the first place), or perhaps the module path (so it can be dynamically imported).
That leaves the issue of initialActions
: AFAIK, if the initial actions were already fired on the server, there's no way to tell the client-side store to skip those actions. Some actions, depending on their side effects, might be necessary to fire on both the client and server, while others you'd want to skip if the server already fired them. This could be handled with some bookkeeping in the module's own store state, like bootstrapped: true
or something, or maybe you could dynamically modify the module to exclude initialActions
when necessary on the client...
Anyway, I wonder if anyone else has thought about this, and what your approach is.
@exogen Thanks for the question. I don't have any experience with Server side rendering. @Stoope has used the library with SSR, see #31 . Will following work
- Create a Module registry, which maps Ids to IModule objects.
- While serializing the store serialize list of module ids loaded.
- When creating a store at client use the data from 2, get the modules from the registry and pas it to createStore or DynamicModuleLoader
Update on how this is going for me so far...
Here's a diagram particular to Next.js with how this roughly needs to work:
It's mostly working fine. The main issue I have right now is that:
-
If add the initial SSR'd modules to the client-side store during
createStore
(with theinitialModules
param) then there is no way to remove them incomponentWillUnmount
, because I don't get access to a handle withremove
like I would if I added them manually. Thus, I'd be stuck with whatever modules the initial SSR'd page happened to load. -
If on the other hand I do add them manually after the store is created, then Redux immediately complains:
Unexpected key "[my state key A]" found in previous state received by the reducer. Expected to find one of the known reducer keys instead: "[my state key B]". Unexpected keys will be ignored.
This is merely a warning and doesn't technically break anything, but I'd prefer it not to happen. The issue is that the ModuleAdded
action is being fired, and Redux is seeing some preloaded state (from SSR) that isn't associated with any current reducer (since, in order to get access to the remove
handle, I needed to add the modules myself rather than in initialModules
).
My immediate problem could be solved by making this line:
https://github.com/Microsoft/redux-dynamic-modules/blob/405a8f64c182e15873f37131db5a757090c77455/packages/redux-dynamic-modules/src/ModuleStore.ts#L177
something like:
store.initialModules = store.addModules(initialModules);
So that one could then do:
store.initialModules.remove();
But I won't PR that just yet, maybe you've got some grander design in mind. :)
Considering that ModuleStore simply calls addModules
right before it returns anyway, I'm actually not sure why doing it myself results in the Redux warning, but letting ModuleStore do it doesn't...
Edit: Now when doing addModules
immediately after createStore
to get a remove
handle, I'm not seeing that Redux warning anymore, so... 🤷♂️
Can you do following instead?
- Have initialActions in the modules added via DynamicModuleLoader
- The action can have payload that is read the json data sent from the server
@exogen did the above work for you?
@navneet-g I don't believe that works in the grand scheme, but I'm still experimenting and figuring out a workable solution.
It might solve the initial hydration (haven't checked yet) although I believe it might throw that Redux "unexpected key" warning. The reason I can't use it in the general case is due to subsequent client-side navigations (the Next Page part of the diagram above). In that case, both getInitialProps
and render
are fired on the client-side – not getInitialProps
on the server and render
on both server+client like the SSR case. So DynamicModuleLoader
can't really be used since the module needs to be added in getInitialProps
(to handle any Redux actions fired there to load data). If DynamicModuleLoader
is used, then it's simply adding + removing another reference count to an already-loaded module, and we have a reference leak (added in getInitialProps
but never removed). If it's added in getInitialProps
, removed at the end of getInitialProps
, and then loaded again via DynamicModuleLoader
, then the state is lost in between getInitialProps
and render since the reference count would drop to zero.
That's why the diagram points out the desired solution: the module is added in getInitialProps
(before the component is even constructed) and not removed until the component is unmounted.
@exogen can you provide an example of how you be able to make it work with Next.js?
Hey folks, this is turning into a blocker for us as well.
Can you do following instead?
- Have initialActions in the modules added via DynamicModuleLoader
- The action can have payload that is read the json data sent from the server
The problem with this approach is that lazy loaded code in SSR is synchronously loaded, which means initial actions fire for the loaded modules. But since this action cannot read the initial JSON sent from the server (while it's running on the server itself), we would then have to make write the action in a way where its a no-op when running on the server, but load the state from the JSON on the client. This significantly increases boilerplate and indirection.
I'm not sure what is the best approach here, but if some sort of hydrator
prop could be passed into the DynamicModuleLoader
react component, it could then do the simple setting of the reducer's slice of state by calling some pre-defined fn. If that approach feels worth exploring, happy to figure a PR
That's why the diagram points out the desired solution: the module is added in
getInitialProps
(before the component is even constructed) and not removed until the component is unmounted.
I haven't had chance to actually test this yet, but it might be possible to remove the modules in componentWillUnmount
using a pattern such as :
...
getInitialProps(ctx) {
//may be serverside or clientside
const removeHandle=store.addModules(modules).remove
if(typeof window !=='undefined') window.removeHandle=removeHandle
...
}
componentWillUnmount() {
//only ever clientside
window.removeHandle && window.removeHandle()
}
// or alternatively
getInitialProps(ctx) {
//may be serverside or clientside
const removeHandle=store.addModules(modules).remove
...
return {...otherprops,removeHandle}
//as functions cannot be JSON serialized then the handle will only be sent if this method is executed clientside
}
componentWillUnmount() {
this.props.removeHandle && this. props.removeHandle()
}
This is merely a warning and doesn't technically break anything, but I'd prefer it not to happen. The issue is that the ModuleAdded action is being fired, and Redux is seeing some preloaded state (from SSR) that isn't associated with any current reducer
I'm not sure that this is necessarily 'just a warning' as if any of the reducers alter the state in response to the ModuleAdded action then the resulting state is formed only from the outputs of the current reducers. If however all the reducers return reducer(oldstate) ===oldstate
then the combined reducer returns the original state object unchanged - ie with the preloaded but not yet used state retained. This means that if any of the dispatched actions (the ModuleAdded action or any initial actions) change the state in any way, the state for all the not-yet-installed modules will be discarded.
Possible ways to rehydrate the store from SSR then might include retrieving state on initial reducer seeding if it has been deleted:
function myReducer(oldState,action){
if (oldState===undefined && typeof window !=='undefined' && window.__STATE_FROM_SERVER) return window.__STATE_FROM_SERVER.myReducer
...
}
Alternatively code something like this into the core reducer implementation or the <DynamicModuleLoader/>
component (as mentioned above) rather than require boilerplate at the top of every reducer
Happy to contribute a PR if wanted
Anyone got this to work properly with nextjs?
Hey. Take a look at my example of using NEXT with dynamic modules. https://github.com/fostyfost/next-with-redux-dynamic-modules
@fostyfost : This is a great example, Thanks for that.
There is problem though, your example seems to be working fine with next's getInitialProps
but it doesn't work when we use getServerSideProps
and getStaticProps
when i converted your users.tsx code
from
UsersPage.getInitialProps = context => {
if (!isUsersLoaded(context.store.getState())) {
context.store.dispatch(UsersPublicAction.loadUsers())
}
return { title: 'Users page' }
}
export default withDynamicModuleLoader(UsersPage, [getUsersModule()])
to
UsersPage.getServerSideProps = context => {
if (!isUsersLoaded(context.store.getState())) {
context.store.dispatch(UsersPublicAction.loadUsers())
}
return { title: 'Users page' }
}
export default withDynamicModuleLoader(UsersPage, [getUsersModule()])
it shows blank page now only
@hellokunji hi! Thank you for your feedback! Currently my example doesn't support modern Next.js GS(S)P-feature but I'm working on it. Also I want to distribute this feature as a standalone library.
@hellokunji hi! Thank you for your feedback! Currently my example doesn't support modern Next.js GS(S)P-feature but I'm working on it. Also I want to distribute this feature as a standalone library.
@fostyfost No problem!, I will be following and waiting for the library updates. 👍
@fostyfost : This is a great example, Thanks for that. There is problem though, your example seems to be working fine with next's
getInitialProps
but it doesn't work when we usegetServerSideProps
andgetStaticProps
The object returned from getServerSideProps
is different to the getInitialProps
Try:
UsersPage.getServerSideProps = context => {
if (!isUsersLoaded(context.store.getState())) {
context.store.dispatch(UsersPublicAction.loadUsers())
}
return {props:{ title: 'Users page' }}
}
export default withDynamicModuleLoader(UsersPage, [getUsersModule()])
@fostyfost : This is a great example, Thanks for that. There is problem though, your example seems to be working fine with next's
getInitialProps
but it doesn't work when we usegetServerSideProps
andgetStaticProps
The object returned from
getServerSideProps
is different to thegetInitialProps
Try:UsersPage.getServerSideProps = context => { if (!isUsersLoaded(context.store.getState())) { context.store.dispatch(UsersPublicAction.loadUsers()) } return {props:{ title: 'Users page' }} } export default withDynamicModuleLoader(UsersPage, [getUsersModule()])
@djsilcock Thanks for the reply, it still doesn't work.
@hellokunji hi! Please, try this library is your issue is still actual. https://github.com/fostyfost/redux-eggs