Rematch Code Deep Dive: Part 2 - The Rematch Core

This is the second part of the Rematch Code Deep Dive Series, focusing on the core code of Rematch.

Unless specified otherwise, the code version in this column is @rematch/core: 1.4.0

Before explaining Rematch core, let’s review the code structure of Rematch:

 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

Based on the structure above, I’ve divided Rematch into the following components:

rematch code structure

Recalling the Rematch import statement import { init } from '@rematch/core', the core represents the core source code of Rematch, which I categorize into two files:

  1. /src/rematch.ts contains the Rematch class, including the initialization method of Rematch and methods for model construction, etc.
  2. /src/redux.ts involves the transformation of code logic from Rematch to Redux, mainly the generation of reducers.

Next, let’s discuss these two parts.

Before understanding the Rematch class, let’s look at Rematch’s export method init, which is the only place to instantiate the Rematch class:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
export interface InitConfig<M extends object = Models> {
  name?: string;
  models?: M;
  plugins?: Plugin[];
  redux?: InitConfigRedux;
}

// incrementer used to provide a store name if none exists
let count = 0;

/**
 * init
 *
 * generates a Rematch store
 * with a set configuration
 * @param config
 */
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();
};

The init() function accepts a configuration object as a parameter and returns a created Rematch store. Inside the function, since initConfig supports property defaults, the mergeConfig() function is first called to fill in the missing parameters. Also, the plugins attribute represents some exported plugin configurations (which will be discussed later in Rematch plugins). These initialization configurations in the plugins also need to be merged:

/src/utils/mergeConfig.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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
/**
 * mergeConfig
 *
 * merge init configs together
 */
export default (initConfig: R.InitConfig & { name: string }): R.Config => {
  const config: R.Config = {
    name: initConfig.name,
    models: {},
    plugins: [],
    ...initConfig,
    redux: {
      reducers: {},
      rootReducers: {},
      enhancers: [],
      middlewares: [],
      ...initConfig.redux,
      devtoolOptions: {
        name: initConfig.name,
        ...(initConfig.redux && initConfig.redux.devtoolOptions
          ? initConfig.redux.devtoolOptions
          : {}),
      },
    },
  };

  // ...

  // defaults
  for (const plugin of config.plugins) {
    if (plugin.config) {
      // models
      const models: R.Models = merge(config.models, plugin.config.models);
      config.models = models;

      // plugins
      config.plugins = [...config.plugins, ...(plugin.config.plugins || [])];

      // redux
      if (plugin.config.redux) {
        config.redux.initialState = merge(
          config.redux.initialState,
          plugin.config.redux.initialState
        );
        config.redux.reducers = merge(
          config.redux.reducers,
          plugin.config.redux.reducers
        );
        // ...
      }
    }
  }
  return config;
};

Rematch is instantiated after the init() calls. Let’s look at its constructor:

 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
/**
 * Rematch class
 *
 * an instance of Rematch generated by "init"
 */
export default class Rematch {
  protected config: R.Config;
  protected models: R.Model[];
  private plugins: R.Plugin[] = [];
  private pluginFactory: R.PluginFactory;

  constructor(config: R.Config) {
    this.config = config;
    this.pluginFactory = pluginFactory(config);
    for (const plugin of corePlugins.concat(this.config.plugins)) {
      this.plugins.push(this.pluginFactory.create(plugin));
    }
    // preStore: middleware, model hooks
    this.forEachPlugin("middleware", (middleware) => {
      this.config.redux.middlewares.push(middleware);
    });
  }
  public forEachPlugin(method: string, fn: (content: any) => void) {
    for (const plugin of this.plugins) {
      if (plugin[method]) {
        fn(plugin[method]);
      }
    }
  }

  // ...
}

In the constructor, the main task is to bind config, pluginFactory, and plugins to the Rematch instance. We’ll skip the plugin part for now.

Here’s a brief explanation of the forEachPlugin() method, which is used to traverse all plugins. If a corresponding hook exists on the plugin (matched through the first parameter method), it calls the second parameter (a callback function) and passes the hook as a parameter back. For example, in the constructor, all plugins’ middleware hooks are collected and merged into the redux.middleware configuration.

After instantiating the Rematch class, its init() method is called:

 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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
export default class Rematch {
  // ...
  protected config: R.Config;
  protected models: R.Model[];

  // ...

  public getModels(models: R.Models): R.Model[] {
    return Object.keys(models).map((name: string) => ({
      name,
      ...models[name],
      reducers: models[name].reducers || {},
    }));
  }

  public addModel(model: R.Model) {
    // ... some validation

    // run plugin model subscriptions
    this.forEachPlugin("onModel", (onModel) => onModel(model));
  }

  public init() {
    // collect all models
    this.models = this.getModels(this.config.models);
    for (const model of this.models) {
      this.addModel(model);
    }
    // create a redux store with initialState
    // merge in additional extra reducers
    const redux = createRedux.call(this, {
      redux: this.config.redux,
      models: this.models,
    });

    const rematchStore = {
      name: this.config.name,
      ...redux.store,
      // dynamic loading of models with `replaceReducer`
      model: (model: R.Model) => {
        this.addModel(model);
        redux.mergeReducers(redux.createModelReducer(model));
        redux.store.replaceReducer(
          redux.createRootReducer(this.config.redux.rootReducers)
        );
        redux.store.dispatch({ type: "@@redux/REPLACE " });
      },
    };

    this.forEachPlugin("onStoreCreated", (onStoreCreated) => {
      const returned = onStoreCreated(rematchStore);
      // if onStoreCreated returns an object value
      // merge its returned value onto the store
      if (returned) {
        Object.keys(returned || {}).forEach((key) => {
          rematchStore[key] = returned[key];
        });
      }
    });

    return rematchStore;
  }
}

In init(), models are first initialized and bound. If a plugin has registered an onModel hook, it is called. Then, a native Redux store is generated and wrapped in a Rematch store. Finally, if a plugin has registered an onStoreCreated hook, it is called. If the hook returns an object, the object’s properties are merged into the Rematch store.

The main part of this process is to explain how to use the createRedux() function to generate a native Redux store, which is the second part of the Rematch core about Redux.

The Redux part mainly transforms the models configured in Rematch into Redux reducers, eventually generating a Redux store.

The file is located at /src/redux.ts, containing only one default export function:

 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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
export default function ({
  redux,
  models,
}: {
  redux: R.ConfigRedux;
  models: R.Model[];
}) {
  const combineReducers = redux.combineReducers || Redux.combineReducers;
  const createStore: Redux.StoreCreator =
    redux.createStore || Redux.createStore;
  const initialState: any =
    typeof redux.initialState !== "undefined" ? redux.initialState : {};

  this.reducers = redux.reducers;

  // combine models to generate reducers
  this.mergeReducers = (nextReducers: R.ModelReducers = {}) => {
    // merge new reducers with existing reducers
    this.reducers = { ...this.reducers, ...nextReducers };
    if (!Object.keys(this.reducers).length) {
      // no reducers, just return state
      return (state: any) => state;
    }
    return combineReducers(this.reducers);
  };

  this.createModelReducer = (model: R.Model) => {
    const modelBaseReducer = model.baseReducer;
    const modelReducers = {};
    for (const modelReducer of Object.keys(model.reducers || {})) {
      const action = isListener(modelReducer)
        ? modelReducer
        : `${model.name}/${modelReducer}`;
      modelReducers[action] = model.reducers[modelReducer];
    }
    const combinedReducer = (state: any = model.state, action: R.Action) => {
      // handle effects
      if (typeof modelReducers[action.type] === "function") {
        return modelReducers[action.type](state, action.payload, action.meta);
      }
      return state;
    };

    this.reducers[model.name] = !modelBaseReducer
      ? combinedReducer
      : (state: any, action: R.Action) =>
          combinedReducer(modelBaseReducer(state, action), action);
  };
  // initialize model reducers
  for (const model of models) {
    this.createModelReducer(model);
  }

  this.createRootReducer = (
    rootReducers: R.RootReducers = {}
  ): Redux.Reducer<any, R.Action> => {
    const mergedReducers: Redux.Reducer<any> = this.mergeReducers();
    if (Object.keys(rootReducers).length) {
      return (state, action) => {
        const rootReducerAction = rootReducers[action.type];
        if (rootReducers[action.type]) {
          return mergedReducers(rootReducerAction(state, action), action);
        }
        return mergedReducers(state, action);
      };
    }
    return mergedReducers;
  };

  const rootReducer = this.createRootReducer(redux.rootReducers);

  const middlewares = Redux.applyMiddleware(...redux.middlewares);
  const enhancers = composeEnhancersWithDevtools(redux.devtoolOptions)(
    ...redux.enhancers,
    middlewares
  );

  this.store = createStore(rootReducer, initialState, enhancers);

  return this;
}

It might look a bit messy, so let me break it down and explain step by step.

First, create a unified reducer for each Model. Since each Model contains multiple reducers, this step creates a unified one, then dispatches to the corresponding individual reducers.

 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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/**
 * isListener
 *
 * determines if an action is a listener on another model
 */
const isListener = (reducer: string): boolean => reducer.indexOf("/") > -1;

export default function ({
  redux,
  models,
}: {
  redux: R.ConfigRedux;
  models: R.Model[];
}) {
  const combineReducers = redux.combineReducers || Redux.combineReducers;
  const createStore: Redux.StoreCreator =
    redux.createStore || Redux.createStore;
  const initialState: any =
    typeof redux.initialState !== "undefined" ? redux.initialState : {};

  this.reducers = redux.reducers;

  this.createModelReducer = (model: R.Model) => {
    const modelBaseReducer = model.baseReducer;
    const modelReducers = {};
    for (const modelReducer of Object.keys(model.reducers || {})) {
      const action = isListener(modelReducer)
        ? modelReducer
        : `${model.name}/${modelReducer}`;
      modelReducers[action] = model.reducers[modelReducer];
    }
    const combinedReducer = (state: any = model.state, action: R.Action) => {
      // handle effects
      if (typeof modelReducers[action.type] === "function") {
        return modelReducers[action.type](state, action.payload, action.meta);
      }
      return state;
    };

    this.reducers[model.name] = !modelBaseReducer
      ? combinedReducer
      : (state: any, action: R.Action) =>
          combinedReducer(modelBaseReducer(state, action), action);
  };

  // initialize model reducers
  for (const model of models) {
    this.createModelReducer(model);
  }

  // ...
}

First, some default values are filled in, including createStore, combineReducers methods, initial initialState, and reducers. Then, each model is traversed to generate a model-level reducer, with model.name as the key, filling the this.reducers object.

There are 2 main points to note here:

  • The generation of action.type. If the reducer method name contains /, then Rematch considers it a reducer for listening (e.g., if there are two Models, modelA and modelB, and modelA has a reducer named foo, then a listening reducer modelA/foo can be defined in modelB. So when the dispatched action is modelA/foo, both reducers will execute). The corresponding action type is the name of that reducer; otherwise, it is ${model.name}/${modelReducerName}.
  • Each model can define a baseReducer. If there is a baseReducer, each model will go through its corresponding baseReducer before reaching the specific reducer.

After creating each Model’s reducer, the next step is to create a rootReducer.

After the Root reducer is created, a Redux store can be generated. Let’s look at the following code:

 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
35
36
37
38
39
40
41
42
43
44
45
export default function ({
  redux,
  models,
}: {
  redux: R.ConfigRedux;
  models: R.Model[];
}) {
  // ...

  const combineReducers = redux.combineReducers || Redux.combineReducers;
  this.reducers = redux.reducers;

  // ...

  // combine models to generate reducers
  this.mergeReducers = (nextReducers: R.ModelReducers = {}) => {
    // merge new reducers with existing reducers
    this.reducers = { ...this.reducers, ...nextReducers };
    if (!Object.keys(this.reducers).length) {
      // no reducers, just return state
      return (state: any) => state;
    }
    return combineReducers(this.reducers);
  };

  this.createRootReducer = (
    rootReducers: R.RootReducers = {}
  ): Redux.Reducer<any, R.Action> => {
    const mergedReducers: Redux.Reducer<any> = this.mergeReducers();
    if (Object.keys(rootReducers).length) {
      return (state, action) => {
        const rootReducerAction = rootReducers[action.type];
        if (rootReducers[action.type]) {
          return mergedReducers(rootReducerAction(state, action), action);
        }
        return mergedReducers(state, action);
      };
    }
    return mergedReducers;
  };

  const rootReducer = this.createRootReducer(redux.rootReducers);

  // ...
}

Now, let’s look at the this.createRootReducer() function. It first calls this.mergeReducers() to combine reducers. After the above steps, each model’s reducers have been collected into a reducerMapObject (this.reducers), and in this.mergeReducers(), combineReducers is called to merge them into the final rootReducer.

It’s important to note that, similar to configuring a baseReducer for each Model, Rematch also allows you to configure a global redux.rootReducers. When dispatching an action, it first checks if there is a matching reducer in redux.rootReducers. If so, it processes through this reducer first, then the final rootReducer dispatches to the specific reducers.

Finally, a Redux store is created and returned:

 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
const composeEnhancersWithDevtools = (
  devtoolOptions: R.DevtoolOptions = {}
): any => {
  const { disabled, ...options } = devtoolOptions;
  /* istanbul ignore next */
  return !disabled &&
    typeof window === "object" &&
    window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
    ? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__(options)
    : Redux.compose;
};

export default function ({
  redux,
  models,
}: {
  redux: R.ConfigRedux;
  models: R.Model[];
}) {
  // ...

  const rootReducer = this.createRootReducer(redux.rootReducers);

  const middlewares = Redux.applyMiddleware(...redux.middlewares);
  const enhancers = composeEnhancersWithDevtools(redux.devtoolOptions)(
    ...redux.enhancers,
    middlewares
  );

  this.store = createStore(rootReducer, initialState, enhancers);

  return this;
}

This part is similar to Redux, but Rematch completely encapsulates it, so I won’t go into detail.

Lastly, let’s look at the creation of the Rematch store, recalling the previous code:

 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
export default class Rematch {
  // ...

  public init() {
    // ...

    // create a redux store with initialState
    // merge in additional extra reducers
    const redux = createRedux.call(this, {
      redux: this.config.redux,
      models: this.models,
    });

    const rematchStore = {
      name: this.config.name,
      ...redux.store,
      // dynamic loading of models with `replaceReducer`
      model: (model: R.Model) => {
        this.addModel(model);
        redux.mergeReducers(redux.createModelReducer(model));
        redux.store.replaceReducer(
          redux.createRootReducer(this.config.redux.rootReducers)
        );
        redux.store.dispatch({ type: "@@redux/REPLACE " });
      },
    };

    // ...

    return rematchStore;
  }
}

As can be seen, the Rematch store adds a name property and a model method on top of the Redux store. The former, I guess, is used to distinguish between multiple Rematch stores, while the latter is used to dynamically add models. Let’s focus on how to dynamically add models:

  1. First, associate the model with the Rematch instance.
  2. Then create a reducer for the model.
  3. Finally, recreate a rootReducer and use Redux’s replaceReducer() method for replacement. A special action is also dispatched to indicate this.

It should be noted that in the code redux.mergeReducers(redux.createModelReducer(model)), the call to redux.mergeReducers is actually redundant and ineffective since its return value is not used. Moreover, in the redux.createRootReducer(this.config.redux.rootReducers) method below, redux.mergeReducers is called again, where it actually takes effect.

In Rematch v1, due to the use of the Rematch instance, many variable objects are directly associated with this and are updated by direct mutation, leading to some redundant or “strange” aspects. In v2, the Rematch instance is replaced by functional programming. However, some variable bindings to this have not been completely removed, so there are still some issues like this in the code, which I will explain separately when writing about the upgrade from v1 to v2.


This concludes the introduction to Rematch core. Next, I will explain the plugin mechanism of Rematch in two parts: the first part will introduce the pluginFactory and Rematch’s two core plugins, and the second part will discuss several third-party plugins developed by the Rematch team to understand this mechanism more deeply.

Finally, I will write two articles, one on the changes in upgrading from Rematch v1 to v2 and the other on the Rematch type system (the biggest change brought by the upgrade to v2) as well as some remaining issues and difficulties of this type system, which I will discuss with you.

Stay tuned!

Related Content