Using local mobx stores in React or we don't need useState

Tags: mobx , react

When writing React components using mobx, you often have to make a choice: use useState to store small states or create a separate mobx model.

For something simple, when you need to store one value, useState can be convenient. However, if you need to store multiple values and/or do complex calculations, using a mobx model is not only more convenient, but it also eliminates the need to delve into the React lifecycle and opens the way for simple and understandable unit testing.

It might look something like this.

import { observer } from 'mobx-react-lite';

class Model {
  constructor(public value: number) {
    makeObservable(this, {
      value: observable,
      double: computed,
    });
  }

  get double() {
    return this.value * 2;
  }
}

const Component = observer<{ value: number }>(({ value }) => {
  const model = useMemo(() => new Model(value), [value]);
  return <div>{model.double}</div>;
});

If value does not change often, then you can stop there. With constant changes, the model will be recreated and all calculations will have to be performed again. To optimize this behavior, you can use useEffect and update the model value in it and remove the dependency from useMemo. In this case, the update of the model value is delayed due to the asynchrony of this technique. Plus, you have to write a lot of extra code each time.

Tired of such torment, it was decided to introduce a new entity - ReactModel.

export abstract class ReactModel<TParams extends Record<string, unknown>> {
  readonly __type__ = null as unknown as TParams;
  get<TKey extends keyof TParams>(key: TKey): TParams[TKey] {
    throw new Error('need to be reimplemented with useModel');
  }
}

The idea is that any local react model should inherit from this class and access react props through the get method.

All the fun happens in the useReactModel hook.

import { useMemo, useRef } from 'react';
import { observable, runInAction, type ObservableMap, comparer } from 'mobx';
import type { ReactModel } from './ReactModel';

export function useReactModel<T extends ReactModel<Record<string, unknown>>>(
  TModel: { new (): T },
  params: T['__type__'],
  cmp: <TKey extends keyof T['__type__']>(
    key: TKey,
    a: T['__type__'][TKey],
    b: T['__type__'][TKey]
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
  ) => boolean = cmpReactModelDefault as any
) {
  const ref = useRef<ObservableMap<string, unknown>>();

  if (!ref.current) {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    ref.current = observable.map(params as any, { deep: false });
  }

  useEffect(() => {
    const $ref = ref.current;
    runInAction(() => {
      Object.entries(params).forEach(([k, v]) => {
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        if (!cmp(k, v as any, $ref.get(k) as any)) {
          $ref.set(k, v);
        }
      });
    });
  }, [params]);

  const model = useMemo(() => {
    const inst = new TModel();
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    inst.get = ((k: string) => ref.current?.get(k as string)) as any;

    return inst;
  }, [TModel]);

  return model as T;
}

function cmpReactModelDefault(_: string, a: unknown, b: unknown) {
  return comparer.identity(a, b);
}

This hook needs to be passed a model class, as well as react props from the component. With this approach, we create a model whose life cycle is tied to the component (the model is created once). We have reactivity due to the fact that we use mobx.observable.map to store parameters. Unfortunately, useEffect generate extra re-renders for each react props updates.

The cmp method allows you to write custom logic to compare changed react props.

The final component will take the following form:

import React from 'react';
import { computed, makeObservable } from 'mobx';
import { observer } from 'mobx-react-lite';
import { ReactModel, useReactModel } from './useReactModel';

class TestModel extends ReactModel<{ value: number }> {
  constructor() {
    super();
    makeObservable(this, {
      double: computed,
    });
  }

  get double() {
    return this.get('value') * 10;
  }
}

const Component = observer<{ value: number }>(({ value }) => {
  const model = useReactModel(TestModel, { value });

  return <button type="button">{model.double}</button>;
});