Frontend
October 10

Универсальный Store в React и Mobx

MobX это простое, опробованное в бою решение для управления состоянием вашего приложения. С помощью него можно хранить и управлять объектами приложения быстро и эффективно.

Очень часто стоит задача получить данные от сервера, сохранить их в память для отображения на клиенте. И в случае их обновления быстро и эффективно перерисовать изменения.

Самый банальный способ это использовать useState.

Например:

type TUser = { id: string; name: string }

const [users, setUsers] = useState<TUser[]>([]);

const loadUsers = async () => {
    const response = api.getUsers();
    setUsers(response.data);
};

Неудобство такого подхода заключается в следующем:

  1. Эти данные доступны только в разрезе одной страницы. Если нужно видеть эти данные на разных страницах, то придётся оборачивать в контекст.
  2. Чтобы обновить данные какого-то пользователя, то придётся пробегаться по массиву, искать нужный элемент и обновлять его данные. Вот так, например:
const updateUserName = (id: string, name: string) => {
    const user = users.find((el) => el.id === id);
    if (!user) return;
    user.name = name;
    setUsers(users.map((el) => el.id === id ? user : el));
};

Как видим слишком много манипуляций для обновления данных. Для удаления тоже придётся фильтровать по id. А если при вставке новых данных надо учитывать, чтоб не получилось дубликатов, то и это дополнительная боль.

Для решения подобных задач обычно предлагается делать Хэш-таблицы, где по id сопоставляется целый объект. Например:

const users = {
    'id-1': { id: 'id-1', name: 'John' },
    'id-2': { id: 'id-2', name: 'Anton' },
    // ...и так далее
};

Преимущество таких решений, что вы точно не создадите дубликата, если будете добавлять нового пользователя или обновить пользователя очень быстро:

// Добавляем нового пользователя
users['id-3'] = { id: 'id-3', name: 'Lidia' };

// Те же данные, но не создастся новая запись
users['id-3'] = { id: 'id-3', name: 'Lidia' };

// Обновляем данные пользователя
const user = users['id-3'];
// ... обновляем его данные
users['id-3'] = user;

Думаю схема понятна. Теперь давайте приступим к MobX.

Удобным форматом реализации Хэш-таблицы может послужить Map. Обычно у данных, которые мы храним на клиенте всегда есть поле id, по которому мы можем хранить эти данные.

Сейчас приведу сразу реализацию всего класса, а потом ниже пройдусь по всем моментам

/**
 * Названия моделей, которые храним в store
 */
export type TModelName =
  | 'user'
  | 'country'
  | 'region'
  | 'city'
  | 'chat'
  | 'chat-message'
  | // ...и так далее
  | 'user';

type TGetManyOptions<T> = {
  filterFn?: (model: T) => boolean;
};

export class ModelStore<T extends ({ id: string } & Record<string, any>)> {
  public modelName: TModelName;
  public objects = observable(new Map<string /* model id */, T>());

  constructor(modelName: TModelName) {
    this.modelName = modelName;

    makeAutoObservable(this);
  }

  public addModel = (model: T) => {
    this.objects.set(model.id, model);
  };

  public removeModel = (id: string) => {
    this.objects.delete(id);
  };

  public clear = () => {
    this.objects.clear();
  };

  public getOne = (id: string) => {
    return this.objects.get(id);
  };

  public getMany = (options?: TGetManyOptions<T>): T[] => {
    let objects = Array.from(this.objects.values());

    if (options?.filterFn) {
      objects = objects.filter(options.filterFn);
    }

    return objects;
  };
}

  • Как видим, класс создан с Generic типами. Подразумеваем, что у моделей есть поле id.
    T extends ({ id: string } & Record<string, any>
  • public modelName: TModelName; — это просто название модели, которое храним. Оно необязательно для вас. Я обычно оставляю для дебага, если потребуется
  • public objects = observable(new Map<string /* model id */, T>()); — собственно хранилище наших объектов в MobX. Map позволяет нам сделать собственно Хэш-таблицу для доступа к данным.

Все основные методы думаю не нуждаются с объяснении. Просто внимательно просмотреть и будет понятно, что они делают и для чего нужны.

Использовать данный Store дальше очень удобно.

// Файл root.store.ts

type TUser = { id: string; name: string; };
const userStore = ModelStore<TUser>('user');

type TChat = { id: string; name: string; };
const chatStore = ModelStore<TChat>('chat');

// ... и так далее

В компоненте:

// Файл users.view.tsx

import React from 'react';
import { observer } from 'mobx-react-lite';
import { userStore } from './root.store';

const UsersView = observer(() => {
    const users = userStore.getMany();
    
    return (
        <>
            {users.map((el) => <div key={el.id}>{el.name}</div>)}
        </>
    );
});

export default UsersView;

На этом всё :)