Rematch Code Deep Dive: Part 5 - Rematch v2, a big step

This is a part of the Rematch Code Deep Dive Series, focusing on the transition to Rematch V2.

From this article onwards, we will delve into Rematch v2.

Before discussing v2, let me share my experience of joining Rematch.

Looking back, I never expected to become a contributor to Rematch, a well-known open-source project. Initially, I used Rematch for a new project at my company. During its use, I found its TypeScript (TS) compatibility to be poor, especially the dispatch, which was not at all compatible with TS. I raised an issue and submitted a corresponding PR, but Rematch did not address it promptly. Consequently, I submitted a PR in our internal scaffold, creating a file rematch.d.ts to override Rematch’s native type definitions and correct most types.

About a week later, Rematch maintainers contacted me via email, asking for help with Rematch’s type issues. I readily agreed, which led to my involvement in the iteration of v2.

The last version of Rematch v1 and the example code version for the previous articles, v1.4.0, was released on 2020-02-23. After that, the Rematch team began iterating on v2 (by then, Rematch’s founder was no longer involved with Rematch), releasing the first pre-release version @rematch/core@2.0.0-next.1 on 2020-07-30.

I joined Rematch on 2020-08-17 (writing this blog on 2021-08-17, a year has flown by) and facilitated the pre-release of @rematch/core@2.0.0-next.2 on 2020-08-19. After 10 pre-release versions, we finally released the official v2 on 2021-01-31, nearly a year in the making.

That’s a brief history of Rematch v2 and my reasons for joining Rematch. In this article, I’ll mainly introduce some changes brought about by the upgrade to Rematch v2. Most of my involvement was in TS type compatibility, so some of these changes are directly related to my contributions, while others are based on ideas I gathered from team members.

Rematch v2 made significant changes to its code and directory structure, using lerna for project management (as Rematch contains some plugins, it naturally fits the monorepo package management scheme of lerna). Let’s first look at the changes in the directory structure:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
...
plugins
|—— ...
|—— loading
|—— immer
|—— select
src
|—— plugins
|  |—— dispatch.ts
|  |—— effects.ts
|—— typings
|  |—— index.ts
|—— utils
|  |—— deprecate.ts
|  |—— isListener.ts
|  |—— mergeConfig.ts
|  |—— validate.ts
|—— index.ts
|—— pluginFactory.ts
|—— redux.ts
|—— rematch.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
...
blog
docs
website
packages
|—— core
|  |—— test
|  |—— src
|  |  |—— bag.ts
|  |  |—— config.ts
|  |  |—— dispatcher.ts
|  |  |—— reduxStore.ts
|  |  |—— rematchStore.ts
|  |  |—— types.ts
|  |  |—— validate.ts
|  |  |—— index.ts
|  |—— node_modules
|  |—— ...
|—— immer
|  |—— test
|  |—— src
|  |  |—— index.ts
|  |—— node_modules
|  |—— ...
|—— loading
|—— select
|—— ...

As you can see, the previous plugins are now managed as packages, and the core Rematch code is also a package called core. The two core plugins and pluginFactory were removed, with their code integrated into core, and new files like bag.ts and dispatcher.ts were added. I will discuss each one separately.

In my view, the most significant change in Rematch v2 is the removal of classes in favor of functions.

First, let’s take a look at the Rematch class in v1 (I will omit most of the code here, if you want to learn all of it, you can refer to the relevant articles in previous columns):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
 * Rematch class
 *
 * an instance of Rematch generated by "init"
 */
export default class Rematch {
  protected config: R.Config;
  // ... other private configs

  constructor(config: R.Config) {}

  public init() {
    // ...

    const rematchStore = {
      name: this.config.name,
      ...redux.store,
      // ...
    };

    return rematchStore;
  }
}

When called:

1
2
3
4
5
6
export const init = (initConfig: R.InitConfig = {}): R.RematchStore => {
  const name = initConfig.name || count.toString();
  count += 1;
  const config: R.Config = mergeConfig({ ...initConfig, name });
  return new Rematch(config).init();
};

Actually, using “classes” here does not highlight any advantages. Now let’s take a look at v2:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
export default function createRematchStore<
  TModels extends Models<TModels>,
  TExtraModels extends Models<TModels>
>(config: Config<TModels, TExtraModels>): RematchStore<TModels, TExtraModels> {
  // ...

  const reduxStore = createReduxStore(bag);

  let rematchStore = {
    ...reduxStore,
    name: config.name,
    // ...
  } as RematchStore<TModels, TExtraModels>;

  return rematchStore;
}

In v2, a function is defined with a descriptive name createRematchStore, and its usage is simpler:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
/**
 * Prepares a complete configuration and creates a Rematch store.
 */
export const init = <
  TModels extends Models<TModels>,
  TExtraModels extends Models<TModels> = Record<string, never>
>(
  initConfig?: InitConfig<TModels, TExtraModels>
): RematchStore<TModels, TExtraModels> => {
  const config = createConfig(initConfig || {});
  return createRematchStore(config);
};

The current Rematch maintainer, semoal, and I share the same viewpoint. He believes JavaScript is better suited for functional programming and prefers this approach. I previously wrote an article: Do you really understand Object-Oriented JavaScript? Is it necessary to use ‘classes’ for programming? In that article, I analyzed the differences between using pure objects and “classes” for programming. Often, we don’t need to use “classes.” In JavaScript, the essence of classes is still functions and objects. JavaScript class inheritance is fundamentally a prototypical chain. You can refer to that if you are interested.

Previous article mentioned Rematch’s plugin system, including pluginFactory and two core plugins, dispatch and effects. However, in v2, these were removed, and the relevant logic was integrated into core.

It is strongly recommended to read the previous column article before proceeding to this section.

I believe the role of pluginFactory in Rematch v1 had two main purposes:

  1. To support exposed. Plugins could use the exposed attribute to expose methods or objects, which would be bound to pluginFactory.
  2. To bind the context this of all hooks to pluginFactory.

In Rematch v1, the two core plugins, dispatch and effects, each exposed dispatch and effects objects for other plugins to access in their hook functions. However, in v2, everything was transformed into functions to simplify the process. The effects were moved to the rematch bag configuration that will be mentioned below, and dispatch was directly operating redux.dispatch.

Honestly speaking, I don’t think the early team did a particularly good job optimizing the pluginFactory part. For instance, binding all hooks’ this to pluginFactory in the previous version facilitated convenient access to dispatch and effects. But in v2, dispatch is directly equivalent to redux.dispatch and needs to be accessed via rematchStore.dispatch, while effects were put into the rematch bag. When calling different hooks, the parameters passed are also inconsistent. However, I guess maybe other plugins didn’t frequently access dispatch and effects in their hooks, so no one paid attention to or pointed out these flaws.

Besides the above, another major issue in v2 is the misuse of the exposed parameter. In v1, plugins could expose some methods or properties using the exposed parameter, such as dispatch and effects, but this exposure was limited to between plugins (since it was mounted on the pluginFactory object). In v2, a specific addExposed function was created to directly expose these methods or properties of the plugins to rematchStore. Personally, I think this is a misunderstanding of the original design concept. Normally, achieving such a purpose should be implemented using the onStoreCreated hook. If it’s just to add this feature, it also seems redundant.

Here is the code for addExposed:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/**
 * Adds properties exposed by plugins into the Rematch instance. If a exposed
 * property is a function, it passes rematch as the first argument.
 *
 * If you're implementing a plugin in TypeScript, extend Rematch namespace by
 * adding the properties that you exposed from your plugin.
 */
function addExposed<
  TModels extends Models<TModels>,
  TExtraModels extends Models<TModels>
>(
  store: RematchStore<TModels, TExtraModels>,
  plugins: Plugin<TModels, TExtraModels>[]
): void {
  plugins.forEach((plugin) => {
    if (!plugin.exposed) return;
    const pluginKeys = Object.keys(plugin.exposed);
    pluginKeys.forEach((key) => {
      if (!plugin.exposed) return;
      const exposedItem = plugin.exposed[key] as
        | ExposedFunction<TModels, TExtraModels>
        | ObjectNotAFunction;
      const isExposedFunction = typeof exposedItem === "function";

      store[key] = isExposedFunction
        ? (...params: any[]): any =>
            (exposedItem as ExposedFunction<TModels, TExtraModels>)(
              store,
              ...params
            )
        : Object.create(plugin.exposed[key]);
    });
  });
}

In v1, dispatch was mainly used to enhance redux.dispatch, supporting chained dispatching of actions, such as dispatch.modelName.reducerName. This logic was moved to the dispatcher.ts file in v2.

The effects plugin did two things:

  1. Exposed global effects.
  2. Used the middleware hook to handle side effects.
  3. Supported chained dispatching of side-effect actions, such as dispatch.modelName.effectName.

In v2, the first point’s effects were moved to the rematch bag, the second point was handled by creating a specific function createEffectsMiddleware, and the third point’s logic was also moved to the dispatcher.ts file.

In v2, an object called rematchBag was added to store globally accessible information, such as the forEachPlugin method from the previous v1 Rematch class, complete models, reduxConfig configuration, and global effects exposed by the effects plugin.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
/**
 * Object for storing information needed for the Rematch store to run.
 * Purposefully hidden from the end user.
 */
export interface RematchBag<
  TModels extends Models<TModels>,
  TExtraModels extends Models<TModels>
> {
  models: NamedModel<TModels>[];
  reduxConfig: ConfigRedux;
  forEachPlugin: <Hook extends keyof PluginHooks<TModels, TExtraModels>>(
    method: Hook,
    fn: (content: NonNullable<PluginHooks<TModels, TExtraModels>[Hook]>) => void
  ) => void;
  effects: ModelEffects<TModels>;
}

In v1, plugins could use four hooks: onInit, middleware, onModel, and onStoreCreated. In v2, onInit was removed (as it was only used in pluginFactory, which has been removed), and new hooks onReducer and onRootReducer were added, while middleware was renamed to createMiddleware.

Previously, the middleware parameter directly accepted a Redux middleware. After being changed to createMiddleware, it now accepts a function, which still returns Redux middleware, but it can pass the aforementioned rematch bag as a parameter. This change facilitates users in customizing their middleware.

In v1, the plugins using middleware were effects, typed-state, and subscriptions (which was removed in v2). In v2, only typed-state remains, and it has not yet utilized the rematch bag parameter.

For example, here is a comparison of usage between v1 and v2:

v1:

1
2
3
4
5
6
const typedStatePlugin = (): Plugin => ({
  // ...
  middleware: (store) => (next) => (action) => {
    // ...
  },
});

v2:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
const typedStatePlugin = <
  TModels extends Models<TModels>,
  TExtraModels extends Models<TModels> = Record<string, any>
>(
  config = DEFAULT_SETTINGS
): Plugin<TModels, TExtraModels> => {
  // ...

  return {
    // ...
    createMiddleware: () => (store) => (next) => (action) => {
      // ...
    },
  };
};

The onInit hook was called in the create method of pluginFactory. I searched through the v1 code and did not find any plugin using this hook, so I am not clear about its specific function.

In v2, due to the removal of pluginFactory, the onInit hook is also gone.

v2 added the onReducer hook on top of onModel, for more granular control over reducer behavior. This hook is called when generating an individual model reducer. If it returns a value, the final reducer is replaced by this return value; otherwise, the original reducer continues to be used.

As previously discussed with the immer plugin, v1’s immer was mainly implemented through the redux.combineReducers configuration, which had an issue of being overridden. Therefore, in v2, the immer plugin was refactored with the onReducer hook, and similarly, the persist plugin was refactored. This approach avoids the problem of combineReducers being overwritten when using immer and persist simultaneously.

This hook is similar to onReducer, but it is called when the final rootReducer is generated. Currently, only the persist plugin uses this hook.

These are the main changes brought about by this upgrade. Although there are many changes, they have little actual impact on code execution, and the use of functional programming has made the code process clearer and easier to understand. However, there are still some issues, such as the upgrade team being different from the original Rematch v1 developers, and the original author no longer maintaining the framework, leading to some misunderstandings of the previous design concepts.

In addition, the v1 code used a lot of this and did not follow a pure functional programming style, with various mutations in the code. In v2, the use of this was reduced, but there were still quite a few mutations in the parameters. These all made the source code more complex and the process difficult to understand and follow.

In the next and final article, I will discuss my area of expertise, the Rematch type system. It is a complex system that I almost entirely rewrote in v2, greatly enhancing Rematch’s usability. However, there are still many remaining issues, some of which I may not be able to solve due to my limited knowledge, and others due to TypeScript’s design limitations. I will discuss these in the next article and hope that more skilled type gymnasts can join us to continuously improve Rematch and create the best experience. Stay tuned!

Related Content