-
This is re-post (at Mark's suggestion) of this SO question, with some updates The QuestionIs it possible to separate out the feature of an RTK-based application that depend on different slices of a the redux store into separate node packages? Assuming so, what is the best way to do that? BackgroundWe have a large, and growing, app that is based around Redux Toolkit. Where possible we try to separate parts of the application into their own node packages. We find there are a lot of benefits to doing this, including:
It's easy enough to do this for cross-cutting things, like logging, http requests, routing, etc. But we would like to go further and modularize the "features" of our app. For example, have the "address book" feature of our application live in a different module than, say, the "messages" feature, with them all composed together via an "app" package. The benefits we see here are ones we have found in other codebases and have been discussed in other places. (E.g., here for iOS). But, in brief: (1) you can see and control intra-app dependencies. For example, you can easily see if the "messages" feature depends on the "address book" feature and make explicit decisions about how you will expose the one feature to the other via what you export; (2) you can build fully testable sub-parts of the app by simply having a "preview" package that only composes in the things you want to test, e.g., you could have a "contact app" package that only depends on the "contact" feature for building and testing just that; (3) you can speed up CI/CD times by not needing to compile (TS/babel), pack/minify, and unit test every part; (4) you can utilize various analytics tools to get more fine-grained pictures of how each feature is developing. There may well be other ways to achieve these things, and some may disagree with the premise that this is a good way to do it. That's not the focus of the question, but I'm open to the possibility it may be the best answer (e.g., some one with significant Redux experience may explain why this is a bad idea). The ProblemWe've struggled to come up with a good way to do this with Redux Toolkit. The problem seems to boil down to -- is there a good way to modularize (via separate node packages) the various "slices" used in RTK? (This may apply to other Redux implementations but we are heavily invested in RTK). It's easy enough to have a package that exports the various items that will be used by the redux store, i.e., the slice state, action creators, async thunks, and selectors. And RTK will then compose those very nicely in the higher-level app. In other words, you can easily have an "app" package that holds the store, and then a "contacts" package that exports the "contacts" slice, with its attendant actions, thunks, selectors, etc. The problem comes if you also want the components and hooks that use that portion of slice to live in the same package as the slice, e.g., in the "contacts" package. Those components/hooks will need access to the global dispatch and the global useSelector hook to really work, but that only exists in the "app" component, i.e., the feature that composes together the various feature packages. Possibilities ConsideredWe could export the global dispatch and useSelector from the "higher" level "app" package, but then our sub-components now depend on the higher level packages. That means we can no longer build alternate higher level packages that compose different arrangements of sub packages. We could use separate stores. This has been discussed in the past regarding Redux and has been discouraged, although there is some suggestion it might be OK if you are trying to achieve modularization. These discussions are also somewhat old. Current thinkingIn-line with Mark's suggestion on the SO question, we are thinking the best way to approach it may be to export an Any thoughts or suggestions welcome. |
Beta Was this translation helpful? Give feedback.
Replies: 7 comments 22 replies
-
For completeness, here's Mark's post on SO: This question has come up in other contexts, most notably how to write selector functions that need to know where a given slice's state is attached to the root state object. Randy Coulman had an excellent and insightful series of blog posts on that topic back in 2016 and a follow-up post in 2018 that cover several related aspects - see Solving Circular Dependencies in Modular Redux for that post and links to the prior ones. My general thought here is that you'd need to have these modules provide some method that allows injecting the root dispatch or asking the module for its provided pieces, and then wires those together at the app level. I haven't had to deal with any of this myself, but I agree it's probably one of the weaker aspects of using Redux due to the architectural aspects. For some related prior art, you might want to look at these libraries: https://github.com/ioof-holdings/redux-dynostore (deprecated / unmaintained, but relevant) Might also be worth filing this same question over in the RTK "Discussions" area as well so we can talk about it further. |
Beta Was this translation helpful? Give feedback.
-
OK, here is a proof of concept for the dependency injection model. https://github.com/sam-mfb/rtk-split It works, but there are a couple of places where I'm hiding typescript issues. I think these issues are not fundamental, however, but just related to an issue typescript has with pnpm symlinks. More info on that at the end. This basically just takes the default redux-typescript template from create-react-app and breaks out the "counter" feature as a separate package. The key thing that lets it work is the following bits in const localStore = configureStore({
reducer: { counter: counterSlice.reducer },
});
type RootState = { counter: CounterState } & Record<string, any>;
type AppThunk<ReturnType = void> = ThunkAction<
ReturnType,
RootState,
unknown,
Action<string>
>;
type SliceDispatch = typeof localStore.dispatch;
export let useSliceSelector: TypedUseSelectorHook<
{ counter: CounterState } & Record<string, any>
> = useSelector;
export let useSliceDispatch = () =>
useDispatch<SliceDispatch & ThunkDispatch<any, any, any>>();
export const initializeSlicePackage = (
useAppDispatch: typeof useSliceDispatch,
useAppSelector: typeof useSliceSelector
) => {
useSliceDispatch = useAppDispatch;
useSliceSelector = useAppSelector;
}; Basically I'm typing RootState (and the derivative types) as any composed state that has at least the state of the slice i'm using in the package (and same for the dispatch). Those are then used throughout this package (i.e. in This all works and is basically what I was hoping to achieve. I think the typing could be improved. Currently to get the type for the local dispatch I'm actually creating it in Javascript. I feel like there should be a way to infer it entirely in Typescript without any runtime, but I haven't spent enough time there. (The TS problem in the current version is masked by typing |
Beta Was this translation helpful? Give feedback.
-
I fixed the TS problem (it just required an explicit import of immer so it could use WriteableDraft in the declaration). That's updated in the repo now. |
Beta Was this translation helpful? Give feedback.
-
I cleaned up the typing and added some documentation. Here's the full solution, which is also on the repo (suggestions and improvements welcome): // RootStateInterface is defined as including at least this slice and any other slices that
// might be added by a calling package
type RootStateInterface = { counter: CounterState } & Record<string, any>;
// A version of AppThunk that uses the RootStateInterface just defined
type AppThunkInterface<ReturnType = void> = ThunkAction<
ReturnType,
RootStateInterface,
unknown,
Action<string>
>;
// A version of use selector that includes the RootStateInterface we just defined
export let useSliceSelector: TypedUseSelectorHook<RootStateInterface> =
useSelector;
// This function would configure a "local" store if called, but currently it is
// not called, and is just used for type inference.
const configureLocalStore = () =>
configureStore({
reducer: { counter: counterSlice.reducer },
});
// Infer the type of the dispatch that would be needed for a store that consisted of
// just this slice
type SliceDispatch = ReturnType<typeof configureLocalStore>["dispatch"];
// AppDispatchInterface is defined as including at least this slices "local" dispatch and
// the dispatch of any slices that might be added by the calling package.
type AppDispatchInterface = SliceDispatch & ThunkDispatch<any, any, any>;
export let useSliceDispatch = () => useDispatch<AppDispatchInterface>();
// Allows initializing of this package by a calling package with the "global"
// dispatch and selector hooks of that package, provided they satisfy this packages
// state and dispatch interfaces--which they will if the imported this package and
// used it to compose their store.
export const initializeSlicePackage = (
useAppDispatch: typeof useSliceDispatch,
useAppSelector: typeof useSliceSelector
) => {
useSliceDispatch = useAppDispatch;
useSliceSelector = useAppSelector;
}; |
Beta Was this translation helpful? Give feedback.
-
Are you aware of the react-boilerplate approach used in react-bolierplate-cra-template? It injects dynamically reducers and sagas. It probably deserves a closer look to see if there good ideas we could take inspiration from. |
Beta Was this translation helpful? Give feedback.
-
Looking into his topic too. We're modularizing our apps in a large monorepo and need to extract features in separate libraries. We've currently tried all the packages and all have their trade offs. In the near future we'll also explore module-federation, so being able to import/inject slices and APIs even at runtime is fundamental. I'll keep updating this thread with our findings. |
Beta Was this translation helpful? Give feedback.
-
BTW, in the year since I made this post, we have fully modularized our application's features using this strategy. Each feature it its own npm package and has its own redux slice that is entirely internal to it, and all the features get composed in a parent app using the dependency injection method explained here. This does not, however, happen at runtime--it's all part of the build process. |
Beta Was this translation helpful? Give feedback.
I cleaned up the typing and added some documentation. Here's the full solution, which is also on the repo (suggestions and improvements welcome):