Skip to content

This issue was moved to a discussion.

You can continue the conversation there. Go to discussion →

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

The problem of code splitting #4010

Closed
Ephem opened this issue Feb 6, 2021 · 12 comments
Closed

The problem of code splitting #4010

Ephem opened this issue Feb 6, 2021 · 12 comments

Comments

@Ephem
Copy link

Ephem commented Feb 6, 2021

This is neither a bug nor a feature request, but rather a problem description, but I was asked to post it anyway. I only know how this problem affects the React implementation, but I was asked to post it here in the main repo because there is a big chance that other frameworks have similar problems.

If you are familiar with Redux code splitting in frameworks other than React, please add context to this issue!

The summary of this issue is that code splitting in Redux (at least in React) is currently cumbersome and hard to get right, often leading to bloated main bundles that includes the logic for the entire application, even if the rest of that application is code split. It is my belief that this is not solvable in "a correct way" (without side effects in render) within the boundaries of Redux, React Redux or Redux Toolkit and I'm writing this issue to explain why.

Code splitting in Redux is implemented via the low level replaceReducer. The docs also include a section on code splitting which demonstrates different approaches to create a code splitting abstraction, like injectReducer. The libraries in use for code splitting are based on some version of that but can offer additional APIs.

So far so good, but things get tricky when you start thinking about where to call these abstractions. To make it easy, the optimal place to call something like injectReducer would be close to the component using that reducer. Something like a hook or wrapping the component in a HoC or a <ReducerProvider> or something like that. That way the code splitting would be granular and easy to use. This is also what I proposed in this RFC and this follow up PR but these and the libraries that implement similar APIs have a fundamental flaw when they try to support SSR.

Since SSR does not support useEffect and you can't inject reducers on a module level there, because modules are shared between each request, the only place you can do it is inside render. This means breaking the render purity rule which is not great. (Some libraries/solutions do this in a class constructor instead, but those shouldn't have side effects either.) This may or may not result in problems now, when Concurrent Mode is launched or further down the road, but breaking render purity might not be a good thing to rely on for a more official code splitting solution (though this is debatable of course).

I only know of two ways to implement this without side effects in render when using SSR:

  • Use a code splitting library that supports running a callback on every use of that module.
  • Inject the reducers ahead of time, before rendering starts
    • This can't be done in Next.js (possibly via getInitialProps, but that is not a recommended API anymore and has other tradeoffs, e.g. breaking incremental static site generation)
    • Very cumbersome even in custom SSR frameworks, but can be done for example by only declaring reducers to inject at the route level, matching route ahead of time and injecting the reducers then.

(Note that because neither of the above solutions works with Next.js, I'm fairly certain it's actually impossible to do reducer code splitting in Next.js without side effects in render currently.)

These solutions lie outside of the scope of the Redux ecosystem, so in my view, I can't come up with a way to fundamentally improve code splitting inside of core.

This is a pretty complex issue and I didn't want to write too much of a monster of a post, so do ask questions if something is unclear. Also, I'd love to be wrong, so do feel free to chip in with ideas! 😄

Update: A good way to approach understanding this problem is to focus only on the custom hook case. Imagine and work through what it would take to call injectReducer inside a useInjectReducer hook and you'll notice that either you do it in useEffect and don't support SSR, or you do it in the body of the hook which is essentially inside of render and thus making render not pure.

@markerikson
Copy link
Contributor

Thanks for writing this up!

I get the general gist of what you're saying here. Unfortunately, I've never dealt with SSR usage myself, so it's a topic I don't have any real experience with or suggestions to offer.

I am curious how other React ecosystem state management libs deal with this question, as well as how other similar tools like VueX and NgRx handle this. Would be interested in seeing comparisons even if the answer is "Vue and Angular just have very different constraints, so this isn't an issue there".

@phryneas
Copy link
Member

phryneas commented Feb 6, 2021

Not a SSR person here, but serious question:

Does this type of SSR code splitting make any sense at all, since stuff like bundle size does not play a role there?

I would imagine that on the server, you probably want to load all reducers at initialization and only do the code splitting/late injection on client side.

True, this would lead to a phase where the reducer would not be injected after initially having been injected and it would increase the size of store contents to be communicated from server to client (since it would contain all initialStates of various to-be-injected reducers). The question is if those cases would pose a realistic problem.

@Ephem
Copy link
Author

Ephem commented Feb 6, 2021

@phryneas That's a good question that I didn't cover in the original post!

The bloated initialState can indeed be a problem, but you can solve that by removing any state that is the same as the initial reducer state (since that will be recreated on the client anyway when the reducers are created). The problem is that you still need to inject the reducer ahead of time or inside render on the client to avoid hydration mismatches.

Edit to clarify: Hydration mismatches are when the markup created on the server and the client does not match. First render on the client needs to produce exact same result as on the server, so you still can't run injectReducer in a useEffect since that will happen after initial render.

@Ephem
Copy link
Author

Ephem commented Feb 6, 2021

Maaaybe you could do something crazy like injecting all reducers on the server as per above and (pseudo-code):

// clientEntry.js

const store = createStore(...);
if (isClient) window.store = store;
// DynamicComponent

if (isClient && window.store) injectReducer(window.store, reducer);

I haven't thought this through, but doesn't feel like a great solution even if it did work?

@Ephem
Copy link
Author

Ephem commented Feb 6, 2021

Does this type of SSR code splitting make any sense at all, since stuff like bundle size does not play a role there?

Oh, and just to comment on this, with serverless and lambdas code size does matter, but definitely less than on the client and for most apps I don't think including all reducers in the server code would be a problem.

With ducks or RTK slices it's just not reducers though but actions and selectors too since they live in the same file, but still.

@phryneas
Copy link
Member

phryneas commented Feb 6, 2021

Maybe turn your "global" pattern around:

Inject a slice reducer on module load and additionally, register it to a global variable. (Since you want a reducer to be present once you start importing the actions and/or selectors as well I guess?)

That would do it for the client/first SSR.

On the server, just inject all reducers that are already registered to that global reducer collection on every store creation.

@Ephem
Copy link
Author

Ephem commented Feb 6, 2021

That would be neat and I remember pursuing this avenue a year ago or so, I just remembered why it sadly doesn't work. 😞

The code for the dynamic components is not parsed and executed immediately, it happens inside of render of the parent component. I'm not sure if this applies to all of next/dynamic, loadable-components and react-universal-component but I think so and know it does with some of them.

@phryneas
Copy link
Member

phryneas commented Feb 6, 2021

Before the client component could actually execute anything imported from the slice module, the whole slice module would need to be parsed though?

Can you give a specific example on how that would not work?

@Ephem
Copy link
Author

Ephem commented Feb 6, 2021

Yes. I think I misunderstood your comment as registering all reducers to a global registry ahead of creating the store by doing that in the module scope. That wouldn't work because of the above, but registering reducers to an already created global store would still work which on rereading seems like what you meant?

But then I don't quite understand how your suggestion turns the pattern around? You would still need access to the store in the module scope, which would mean making it global on the client? Did you mean it as just a convenient way to inject all reducers on the server and avoiding a manual step for that? Still, registering them to a global variable would happen upon the first render on the server unless you preload the modules to execute that code explicitly ahead of render, which is possible in many frameworks through an extra step.

@Ephem
Copy link
Author

Ephem commented Feb 6, 2021

I need to think some more about this. My gut feeling is that it could be a possible solution in some or even many cases, but might not be general enough to solve all cases for all frameworks. Maybe not a solution to base an official API surface on, but could be generalizable enough to warrant adding a pattern for in the docs (though if you include scrubbing unused initialState it might get large). Too tired to think it through all the implications right now though so might be wrong both ways. 😄

(On that note, since client side is a lot simpler, adding a pattern for where and how to call injectReducer in the client side case to the docs would be a nice thing!)

@phryneas
Copy link
Member

phryneas commented Feb 6, 2021

Well, your point was that "module scope" is only read once for all invocations for SSR, so injecting on module load is not an option, right?

My point is that "injecing on module load" will work just fine for the client. And for the server, you would do "register on module load in a central place". And on every subsequent store creation on the server, you can already inject all those "registered reducers" into that newly created store.

That way, you would be in that situation of "server has all reducers, client only the required ones".
Leaves the "hydration mismatch" problem, but I don't necessarily think so: if you follow the ducks pattern and import reducer, selectors and action creators from the same file, you can ensure that the correct reducer will always be injected before a selector is executed.

@Ephem
Copy link
Author

Ephem commented Feb 6, 2021

Well, your point was that "module scope" is only read once for all invocations for SSR, so injecting on module load is not an option, right?

Yes, for the server this is not possible (technically it could be with isolates or other fancy solutions which isolates each request into it's own scope, but that's complex and can't be an official solution so lets leave that aside).

My point is that "injecing on module load" will work just fine for the client. And for the server, you would do "register on module load in a central place". And on every subsequent store creation on the server, you can already inject all those "registered reducers" into that newly created store.

Unless modules are explicitly not dynamically loaded on the server, or explicitly preloaded ahead of time, that registration wont happen until that particular module has been rendered at least once, leaving the first render of any path broken. Now that I think about this, either of those solutions would pretty much require no code splitting on the server, which would definitely be bad in the serverless case.

Leaves the "hydration mismatch" problem, but I don't necessarily think so

This is indeed not a problem in this solution.

Thanks for clarifying, I indeed misunderstood the first time and understood you correctly the second time. 😄

This issue was moved to a discussion.

You can continue the conversation there. Go to discussion →

Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants