Search code examples
reduxreact-redux

Modularize a Redux Toolkit application


The Question

Is 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?

Background

We 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:

  • Maintainability of codebase
  • Fine-grained control over intra-application dependencies
  • Testability

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 Problem

We'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 Considered

  1. We 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.

  2. 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.

The Question (Again)

Is 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?

While I'm primarily interested if if/how this can be done in RTK, I'd also be interested in answers--especially from folks with experience with RTK/redux on large apps--as to whether this is Bad Idea and what other approaches are taken to achieve the benefits of modularization.


Solution

  • Following @markerikson's suggestion, here's the solution I've come up with. The basic idea involves a dependency injection model where the higher level 'app' package:

    • imports the 'feature' package's slice
    • composes a store with it
    • calls an initialize function also exported from the 'feature' package which injects the dispatch and state (actually the hooks that wrap them, but you could do it either way).

    The last part is what allows the 'feature' package to stay un-coupled from the 'app' package.

    The 'feature' package also has some typing that defines root state and app dispatch interfaces as types that include at least the local state and dispatch of that feature.

    Here's the key code, using the redux-typescript template in create-react-app as a starting point and extracting the counter feature into a separate package. The code is in the counterSlice module

    // 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;
    };
    

    A working example of this solution is available in this rush repository.