Encapsulating mobx 6 + typescript to realize React state management -- mobx encapsulation

explain

Compared with the centralized state management of redux, mobx is more concise and flexible in concept. We only need to create an "observable" object, and then turn the component into an "Observer" to monitor the changes of observable objects and respond. But for the convenience of teamwork, improve the development efficiency. It is necessary to do some encapsulation properly

Encapsulation principle

  1. Encapsulate the tedious and repetitive parts, so that there are less references and less configuration in daily use
  2. Abstract the general part and provide more convenient interface and method call
  3. With the help of Typescript to provide more comprehensive prompt and error correction capabilities, try to use type inference to reduce the amount of code used
  4. Fully consider the use scenarios, reduce the frequency of subsequent changes

Encapsulation target

  1. Create a Store through Class, and the Store created will have a general action, which does not need to be written repeatedly
  2. There is connectivity between stores, and each sub store can access the data and methods of other stores
  3. Adopt the centralized management scheme, that is, all store s will be aggregated into one
  4. Use makeAutoObservable to turn data into "observable data" to reduce user's operation (makeObservable needs to enumerate and define observable data and action types)
  5. store can be passed through Provider and injected into component through inject, and reasonable TS type prompt can be provided
  6. The "class method" attached to the component wrapped by inject needs to be retained
  7. Can use ref as expected (including functional components)

1. Implementation details of mobx package

1. Encapsulate the creation of Store and extract the general action

Expected goal

  1. Create a Store through Class, and the Store created will have a general action, which does not need to be written repeatedly
  2. There is connectivity between stores, and each sub store can access the data and methods of other stores
  3. Adopt the centralized management scheme, that is, all store s will be aggregated into one
  4. Use makeAutoObservable to turn data into "observable data" to reduce user's operation (makeObservable needs to enumerate and define observable data and action types)

Note: because the objects wrapped by makeAutoObservable cannot contain super or subclass (an error will be reported), encapsulation cannot be realized through class inheritance.

1. First, encapsulate the general action (store/baseActions.ts)

The general action is maintained on one object, which makes it easy to handle TS types

import { STORE_CONST } from './constants';

type TUpdateFn<T> = (storeInstance: T) => void;

const baseActions = {
  // A general method to update the store. There will be TS prompt and check when using it
  updateStore(param: {} | TUpdateFn<{}>): void {
    if (typeof param === 'function') {
      param(this);
    } else {
      Object.keys(param).forEach((key: string) => {
        this[key] = param[key];
      });
    }
  },
  // A general method to reset store
  resetStore(): void {
    const Constructor = _.get(this, 'constructor');
    const initialData = new Constructor();
    const ignore = [STORE_CONST.GLOBAL_STORE_NAME.toString()].concat(Object.keys(baseActions));

    Object.keys(initialData).forEach((key: string) => {
      const value = initialData[key];
      if (_.isFunction(value) || ignore.includes(key)) return;

      this[key] = value;
    });
  },
};

type Reset<T, K extends keyof T, O> = {
  [P in keyof T]: P extends K ? O : T[P];
};

type TempBaseActions = typeof baseActions;
export type TUpdateStore<T extends {}> = (param: Partial<T> | TUpdateFn<T>) => void;

// The key point is to associate the type of BaseAction with the type of the incoming T(Store), so that there will be TS check and prompt
export type TBaseActions<T> = Reset<TempBaseActions, 'updateStore', TUpdateStore<T>>;

export default baseActions;
2. Encapsulate the tool method to change Store data into "observable" (store/enhanceStore.ts)

The goal is to make the store Observable and integrate the actions in baseAction. You can access other stores through this.global.xxstore in the store

Although makeAutoObservable can't handle objects instantiated after inheritance, we can insert attributes into the objects and then give them to makeAutoObservable for processing, provided that the type of TS is well handled

import { makeAutoObservable } from 'mobx';
import { STORE_CONST } from './constants';
import baseActions, { TBaseActions } from './baseActions';

const baseActionsKeys = Object.getOwnPropertyNames(baseActions);

function enhanceStore<T, N>(storeInstance: T, globalStore?: N): T & TBaseActions<T> {
  baseActionsKeys.forEach((key: string) => {
    if (key === 'constructor') return;
    const descriptor = Object.getOwnPropertyDescriptor(baseActions, key);
    Object.defineProperty(storeInstance, key, descriptor!);
  });

  // Keep globalStore as an attribute of the store so that other stores can be accessed through this.global.xxstore
  if (globalStore) {
    Object.defineProperty(storeInstance, STORE_CONST.GLOBAL_STORE_NAME, {
      value: globalStore,
      configurable: true,
    });
  }

  /**
      Why not use extendObservable to extend properties and methods, because it conflicts with the type declaration in the class:
      1. We pass in storeInstance as an instance, which is used to construct the class in order to use baseAction or globalStore in the class structure
      There will be related type declaration, and inevitably there will be property initialization at the same time
      2. extendObservable It is used to extend new attributes to objects. If you have initialized attributes and then use extendObservable to extend them, there will be errors that are not easy to implement
  */
  return makeAutoObservable(storeInstance as T & TBaseActions<T>);
}

export default enhanceStore;
3. How to manage the Store (store/index.ts) in daily development
  1. Create the ModuleStore of a module
  2. Merge the ModuleStore into the GlobalStore
// Create a global store and build a communication bridge between stores
import enhanceStore from 'common/store/enhanceStore';
import RemoteStore from '../../modules/remote/RemoteStore';
import UserStore from '../../modules/user/UserStore';

// The Store encapsulated by enhanceStore will get the correct type prompt through type inference
// Establish a link between stores by passing in the second parameter this, and access other stores through this.global.xxstore
class GlobalStore {
  public remoteStore = enhanceStore(new RemoteStore(), this);
  public userStore = enhanceStore(new UserStore(), this);

  /**
    Define two global methods to handle the default success or failure prompt
    TODO: Do you need to put it in baseAction?,
  */ 
  public handleError = () => {};
  public handleSuccess = () => {};
}

type TGlobalStore = GlobalStore;

export * from 'common/store/baseActions';
export { TGlobalStore };

export default new GlobalStore();


// Store case of a module
import { TGlobalStore, TUpdateStore } from 'portalBase/store';

class UserStore {
  /**
    Store External, that is, the method in baseAction can be accessed directly in the component
    But inside the Store, if you want to access the methods in baseAction or access the global Store 
    You need to declare the following types
  */
  private readonly global: TGlobalStore;
  public updateStore: TUpdateStore<UserStore>;
  public resetStore: () => void;
  public users: { name: string; tel: number }[] = [];

  public increaseUser = (user: { name: string; tel: number }) => {
    // 1. Directly process "observable data" in action“
    this.users.push(user);
    // 2. You can also use the method in baseAction to check the TS type
    this.updateStore({users: [user]});
    // 3. The contents of other stores can be accessed directly in the store
    console.log(this.global.remoteStore.counter);
  };
}

export default UserStore;

2. Encapsulate Provider inject to provide store context for components

Although "mobx react" originally provided Provider inject, its official website has already stated that it is no longer recommended to use these two APIs, instead, it is recommended to build them through React.createContext

Moreover, these two APIs are somewhat awkward to use

  • Provider supports passing in stores, but it needs to be passed one by one, and then it will be merged into a root object when it is used. In this case, the stores are disconnected, and it is not easy to refer to each other
  • inject doesn't provide better TS type prompt. We need type Props = {userStore?: UserStore} the userStore passed in becomes an optional value, which is troublesome to use

To sum up, we need to establish a set of mobx store delivery and use scheme

Expected use effect:

  1. store can be passed through Provider and inserted into component through inject, and reasonable TS type prompt can be provided
  2. The "class method" of the component wrapped by inject needs to be retained
  3. Can use ref as expected (including functional components)
1. Encapsulate the method of creating context: createContext (store/createStoreContext.ts)
import { observer } from 'mobx-react';
import React from 'react';
import hoistNonReactStatics from 'hoist-non-react-statics'; // A tool for copying static methods of a class to other classes (avoiding static methods built into React)

// A Type to get components Props
type GetProps<C> = C extends React.ComponentType<infer P>
  ? C extends React.ComponentClass<P>
    ? React.ClassAttributes<InstanceType<C>> & P
    : P
  : never;

// Define Ref type
type InjectorRef<C> = C extends React.ComponentType<infer P>
  ? C extends React.ComponentClass<P>
    ? InstanceType<C>
    : C extends React.ForwardRefExoticComponent<
        React.PropsWithoutRef<P> & React.RefAttributes<infer T>
      >
    ? T
    : never
  : never;

type IReactComponent<P = any> =
  | React.ClassicComponentClass<P>
  | React.ComponentClass<P>
  | React.FunctionComponent<P>
  | React.ForwardRefExoticComponent<P>;

/**
  Define a method to create context, which will output a series of tools such as Provider inject
  These tools are associated with the incoming generic T (global store)
*/
function createContext<T extends {}>() {
  const StoreContext = React.createContext<T>({} as T);

  interface Props {
    store: T;
    children: React.ReactNode;
  }

  // 1. Top level store packaging
  const Provider: React.FC<Props> = (props: Props) => {
    const { store, children } = props;
    return <StoreContext.Provider value={store}>{children}</StoreContext.Provider>;
  };

  // 2. Functional components directly obtain the full store
  const useStore = (): T => React.useContext(StoreContext);

  // 3. Functional | class component, inject the specified store and convert it to observer
  function inject<K extends {}>(parseFn: (store: T) => K) {
    return <C extends IReactComponent<K & {}>>(
      Component: C,
      options: { isObserver: boolean } = { isObserver: true }, // Can be used to close observer
    ) => {
        const component = options.isObserver ? observer(Component) : Component;
        const Injector = React.forwardRef(
          (props: Omit<GetProps<C>, keyof K | 'ref'>, ref: React.ForwardedRef<InjectorRef<C>>) => {
            const store = React.useContext(StoreContext);
            const injectedStore = parseFn(store);
            const newProps = { ...props, ...injectedStore, ...(ref ? { ref } : {}) };
        
            return React.createElement(component, newProps);
          },
        );
        
        Injector.defaultProps = { ...component.defaultProps };
        Injector.displayName = `inject(${Component.name || Component.displayName})`;
        return hoistNonReactStatics(Injector, Component);    };
  }

  // 4. Functional | class component, replace it with observer (while maintaining static method, directly using observer will lose static method and static attribute)
  function convert<C extends IReactComponent<{}>>(Component: C) {
    return hoistNonReactStatics(observer(Component), Component);
  }

  return { StoreContext, Provider, useStore, inject, convert };
}

export default createContext;
2. After creating the global Store, create the Provider (store/index.ts)

The execution of createContext depends on the incoming GlobalStore type, so the creation of context and global store will be put together

import enhanceStore from 'common/store/enhanceStore';
import createContext from 'common/store/createStoreContext';
import RemoteStore from '../../modules/remote/RemoteStore';
import UserStore from '../../modules/user/UserStore';

class GlobalStore {
  public remoteStore = enhanceStore(new RemoteStore(), this);
  public userStore = enhanceStore(new UserStore(), this);

  public handleError = () => {};
  public handleSuccess = () => {};
}

type TGlobalStore = GlobalStore;

// Created from the incoming GlobalStore
const { Provider, StoreContext, useStore, inject, convert } = createContext<TGlobalStore>();

export * from 'common/store/baseActions';
export { TGlobalStore, Provider, StoreContext, useStore, inject, convert };

export default new GlobalStore();
3. How to use store in daily development
// 1. Provider is used in the outermost layer of the project
import globalStore, { Provider } from 'portalBase/store';
import RootContainer from './modules/root/routes';

ReactDOM.render(
  <Provider store={globalStore}>
    <RootContainer />
  </Provider>,
  document.getElementById('root'),
);


// 2. Inject store into the component
import { TGlobalStore, inject } from 'portalBase/store';

interface Props {
  userStore: TGlobalStore['userStore'];
}

class User extends React.Component<Props> {
  render(): React.ReactNode {
    const { userStore } = this.props;

    return (
      <div>
        <p>user length: {userStore.users.length}</p>
        <UserCreateForm userStore={userStore} />
        <UserList userStore={userStore} />
      </div>
    );
  }
}

// Injecting store with inject
export default inject((store) => {
  return { userStore: store.userStore };
})(User);


// The 3. component calls the observable data, but not the store.
import { TGlobalStore, convert } from 'portalBase/store';
import UserLess from '../style/user.less';
import UserItem from './UserItem';

interface Props {
  userStore: TGlobalStore['userStore'];
}

const UserList: React.FC<Props> = (props: Props) => {
  const { userStore } = props;
  console.log('render User List');
  return (
    <ul className={UserLess.main}>
      <li>
        <span>full name</span>
        <span>Telephone</span>
      </li>
      {userStore.users.map((user: { name: string; tel: number }) => {
        return <UserItem user={user} key={user.name} />;
      })}
    </ul>
  );
};

// Use convert instead of mobx react observer to turn components into observers
export default convert(UserList);

// 4. The component only refers to the store, but does not call the "observable data" in it“
import { Button, Form, Input, InputNumber } from 'antd';
import { TGlobalStore } from 'portalBase/store';
import UserLess from '../style/user.less';

interface Props {
  userStore: TGlobalStore['userStore'];
}

const UserCreateForm: React.FC<Props> = (props: Props) => {
  const { originalStore } = props;
  const onFinish = (values: { name: string; tel: number }) => {
    // Only action is called instead of "observable data"“
    originalStore.increaseUser(values);
  };

  console.log('render user create Form');
  return (
    <header className={UserLess.header}>
      <Form onFinish={onFinish}>
        <Form.Item label="full name" name="name">
          <Input />
        </Form.Item>
        <Form.Item label="Telephone" name="tel">
          <InputNumber />
        </Form.Item>
        <Form.Item label="module" name="module">
          <Button type="primary" htmlType="submit">
            create
          </Button>
        </Form.Item>
      </Form>
    </header>
  );
};

// In order to improve performance or reduce unnecessary render, functional components need to be wrapped by React.memo, while class components use React.PureComponent 
export default React.memo(UserCreateForm);

Tags: React encapsulation TypeScript mobx

Posted by Frozen Kiwi on Sat, 15 May 2021 09:01:02 +0930