На прошлой неделе впервые поучаствовал в конференции по Frontend, где один из докладчиков, расказывал, как удачно его команда переехала с Redux на Mobx. Главным преимуществом он назвал отсутствие бойлерплейта и ускорение разработки в полтора раза.
Я прочитал несколько статей и посмотрел другие доклады, где все как один говорят, что Mobx лучше, чем Redux. Возможно это и так, но почему в сравнение всегда идет Redux, а не Redux-Toolkit, я не понимаю. Попытаемся конструктивно посмотреть действительно ли Mobx настолько хорош как о нем говорят.
Главный аргумент адептов Mobx звучит примерно так
При разработке на Redux приходится писать тону шаблонного кода, чтобы все работало. Нужно написать action-ы и selectors-ы.
Для примера давайте напишем самую простую логику для запроса постов и изменения счетчика и посмотрим сколько строчек кода мы сможем сэкономить.
import { makeAutoObservable } from "mobx";
import { IPromiseBasedObservable, fromPromise } from "mobx-utils";
/* Типизация */
const PostListSchema = z.object({
id: z.number(),
title: z.string(),
description: z.string(),
tag: z.string(),
tags: z.array(z.string()),
image: z.string(),
progress: z.number(),
progressTotal: z.number()
})
type PostListModel = z.infer<typeof PostListSchema>
/* Запрос на получение данных */
export const fetchPostList = async (limit: number) => {
try {
const response = await _api.get<PostListModel[]>(`api/posts`)
if (!response.data) {
throw new Error("Ошибка")
}
return response.data.data
} catch {
throw new Error("Ошибка")
}
}
/* Создание стора */
class PostListStore {
posts?: IPromiseBasedObservable<PostListModel[]>
counter: 0
constructor() {
makeAutoObservable(this)
}
incrementCounter = () => {
this.counter += 1
}
decrementCounter = () => {
this.counter -= 1
}
fetchCoursesData = (limit: number) => {
this.courses = fromPromise(fetchPostList(limit))
}
}
export const postListStore = new PostListStore()
Теперь попробуем написать такую же логику на Redux-Toolkit. Но чтобы избежать предвзятости в нашей оценке давайте попросим chatGPT написать код за нас.
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { AppThunk } from "./store";
/* Типизация */
interface PostListState {
posts: PostListModel[] | null;
loading: boolean;
error: string | null;
counter: number;
}
const initialState: PostListState = {
posts: null,
loading: false,
error: null,
counter: 0,
};
/* Создание слайса */
const postListSlice = createSlice({
name: "postList",
initialState,
reducers: {
incrementCounter(state) {
state.counter += 1;
},
decrementCounter(state) {
state.counter -= 1;
},
},
extraReducers: (builder) => {
builder
.addCase(fetchPostListAsync.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchPostListAsync.fulfilled, (state, action: PayloadAction<PostListState[]>) => {
state.loading = false;
state.posts = action.payload;
})
.addCase(fetchPostListAsync.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message ?? "Ошибка";
});
},
});
export const { incrementCounter, decrementCounter } = postListSlice.actions;
export default postListSlice.reducer;
/* Запрос на получение данных */
export const fetchPostListAsync = createAsyncThunk("fetchPostList", async () => {
try {
const response = await axios.get("/api/posts")
if (!response.data) {
throw new Error("Ошибка")
}
return response.data
} catch {
throw new Error("Ошибка")
}
})
Реализация кода очень похожа, единственное, что в mobx это выглядит немного проще. Однако в сумме разница в 10 строчек, не могу назвать это бойлерплейтом. Actions писать тоже не нужно toolkit все делает за нас.
В рамках эксперимента, давайте попросим chatGPT написать компонент PostList с использованием Mobx и Redux-Toolkit.
/* Код с использованием Mobx */
import React, { useEffect } from "react";
import { observer } from "mobx-react-lite";
import { postListStore } from "../stores/postListStore";
import { IPromiseBasedObservableState } from "mobx-utils";
const PostListMobX: React.FC = observer(() => {
useEffect(() => {
postListStore.fetchCoursesData(10); // Загружаем посты при монтировании компонента
}, []);
const { state } = postListStore.posts ?? {};
switch (state) {
case "pending":
return <div>Loading...</div>;
case "rejected":
return <div>Error: Failed to fetch posts</div>;
case "fulfilled":
return (
<div>
{postListStore.posts?.value.map((post) => (
<div key={post.id}>
<h2>{post.title}</h2>
<p>{post.description}</p>
{/* Другие поля поста */}
</div>
))}
</div>
);
default:
return null;
}
});
export default PostListMobX;
Возможно стоит отметить, что с кодом для Mobx у GPT возникли трудности и правильный результат удалось получить только с четвертой попытки.
/* Код с использованием Redux-toolkit */
import React, { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "../store";
import { fetchPostListAsync } from "../postListSlice";
const PostListRedux: React.FC = () => {
const dispatch = useDispatch();
const { posts, loading, error } = useSelector((state: RootState) => state.postList);
useEffect(() => {
dispatch(fetchPostListAsync(10)); // Загружаем посты при монтировании компонента
}, [dispatch]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!posts) return null;
return (
<div>
{posts.map((post) => (
<div key={post.id}>
<h2>{post.title}</h2>
<p>{post.description}</p>
{/* Другие поля поста */}
</div>
))}
</div>
);
};
export default PostListRedux;
Опять же результат примерно одинаковый в плане количества кода. Однако решение с использованием Redux-Toolkit смотрится проще.
На мой взгляд само сравнение не Toolkit версии с Mobx, крайне странно. Я думаю, это сравнение имело актуальность в 2020 году может быть, но в 2024 точно нет. Для себя я все таки сделаю вывод, что оба инструмента не заставляют разработчика писать "тону" шаблонного кода.
Комментарии (72)
gen1lee
19.04.2024 18:10В redux можно вообще без блоилерплейта и без redux-toolkit. Там можно как угодно, и все это работает без классов и без магии, без всяких Observable и high order component, в функциональном стиле. Максимально простая библиотека, которую можно написать за один вечер самому. Потому и популярнее чем MobX.
artemmorozov13 Автор
19.04.2024 18:10+1Согласен, да. Очень часто слышу от людей, что redux г*овно т.к. много шаблонного кода, решил посмотреть, так ли это
gen1lee
19.04.2024 18:10Ну вы можете лицезреть здесь уровень тех, кто так говорит - сплошная некомпетентность, незнание основ redux и react, ни на один мой комментарий нет конструктивной критики или диалога, только минусы.
ivankoltsov
19.04.2024 18:10На простом примере для MobX отдельный стор не нужен.
artemmorozov13 Автор
19.04.2024 18:10При разрастании, как измениться структура, да будет много сторов и редюсеров, но схема таже. Кончечно, можно было все в стейт запихнуть или контекст, но статья не об этом
Modin
19.04.2024 18:10Так, круто, с бойлерплейтом разобрались. Теперь давайте разберемся с оптимизацией. Что предлагает редакс и mobx. Чтобы было проще предлагаю посмотреть, зачем нужен observer и причем тут proxy или get/set
Modin
19.04.2024 18:10В дополнение, предлагаю вам рассказать, что может предложить Mobx и redux в части локальных стейтов, когда не нужна «глобальная область видимости», а нужен конкретный стор для конкретного компонента, который будет работать как useState, но не наследовать его недостатков.
artemmorozov13 Автор
19.04.2024 18:10Можно какой то интересный бизнес кейс, когда вам пришлось реализовывать такую логику, мне сразу в голову ничего не приходит
Modin
19.04.2024 18:10Конечно. У вас есть список пользователей, при клике на одном из них вы переходите к странице с личной информацией, на которой у вас есть «жирный клиент» с возможностью добавлять, удалять и редактировать поля. Хранить такой стор как глобальный плохая практика, ведь при изменении юзера вам нужно делать условный «reset” стора, чтобы данные от прошлого юзера были затерты
gen1lee
19.04.2024 18:10id пользователя передается в экран, все пользователи хранятся в глобальном сторе. Вы очень слабый программист и пишите ерунду.
Modin
19.04.2024 18:10Вы очень слабый программист, если получаете весь список со всеми атрибутами в одном массиве. Вы и ваши бэк энд разработчики
artemmorozov13 Автор
19.04.2024 18:10+1Я думаю он имел ввиду щапрашивать профиль отдельно и добавлять его через entityAdapter, а если он уже запрашивался, то доставать по id.
Я думаю что оба варианта реализации хороши
gen1lee
19.04.2024 18:10Я нигде не писал про то, как они получаются. Они получатся могут как списком, с экрана со списком, и это абсолютно нормально, так и по отдельности.
mayorovp
19.04.2024 18:10И получаем утечку памяти при длительном использовании приложения?
gen1lee
19.04.2024 18:10"Утечку памяти" в пару килобайт? При желании всегда можно удалять объекты, которые не нужны, но это чистая преждевременная оптимизация в большинстве случаев, так как они практически ничего не занимают. Так еще и при повторном открытии экрана не будет показан спинер на весь экран.
mayorovp
19.04.2024 18:10Да пусть и в пару килобайт. Некоторые пользователи годами вкладки не закрывают...
gen1lee
19.04.2024 18:10Городить костыли вместо useState для локального состояния это очень сомнительная идея.
Modin
19.04.2024 18:10+2Городить спред операторы и безумную логику без оптимизации - вот это сомнительная идея. По факту, useState хорош либо когда мы «получили данные и забыли», либо когда нам нужен boolean | string | number в useState.
gen1lee
19.04.2024 18:10Для более сложного состояния придумали useReducer. Почитайте документацию чем городить костыли.
gun_dose
19.04.2024 18:10+1А можно подробнее про недостатки useState? Я вообще противник того, чтобы класть в redux то, что никогда не пригодится за пределами конкретного компонента. И описанную вами ниже задачу я бы решал именно через useState.
Почему я против того, чтобы хранить такое в redux? У меня есть очень большой проект на next.js и там больше десятка разных типов страниц и стэйты всех страниц засунуты в redux, в итоге при загрузке страницы next data весит порядка 2МБ, но большая часть глобального стейта - это дефолтные значения стейтов других страниц. Плюс запрос getServerSideProps занимает очень много времени (попядка 2 секунд), т.к. вместо того, чтобы запросить json данные с бэка и передать их на фронт, каждый раз на сервере происходит инициализация redux, запуск всего его жизненного цикла, потом сериализация стейта и отправка этого клиенту. Причём опять же большая часть респонса не нужна на запрашиваемой странице.
artemmorozov13 Автор
19.04.2024 18:10Обязательно напишу об оптимизации, пока что решил посмотреть на главный аргумент засирания Redux, комюнити mobx
Modin
19.04.2024 18:10+1Это один из главных аргументов. Но это дело вкусовщины. Нравится вам бойлерплейт и идеология flux, нет проблем. Проблема в том, что это не избавляет вас от «проблем» реакта, таких, как например, always new свойств. С Mobx useCallback, useMemo, memo, можно позабыть, потому что он решает проблемы связанные с ними
artemmorozov13 Автор
19.04.2024 18:10+1Я согласен, что это все дело вкуса, я не старался выставить один стейт менеджер лучше другого, а говорю, что аргументы о бойлерплейте неприемлимы.
По поводу оптимизации, если все правильно писать, то проблем никаких не будет, в redux есть все для оптимизации и мемоизация не нужна. Другое дело, что пишут все по разному. Не раз видел, как в mobx сторах, люди мапили или фильтровали.
Modin
19.04.2024 18:10В редаксе и прочих библиотеках где не производится deep comparsion, не обойтись без встроенных инструментов реакта. Или смириться с неизбежным перерендерингом компонентов
gen1lee
19.04.2024 18:10deep comparison это признак плохого кода и плохой оптимизации, и в react и redux он как раз не нужен из за грамотной архитектуры. Встроенные инструменты реакта вообще нужны для самого реакта в первую очередь, редакс обходится reselect если вообще нужно.
Modin
19.04.2024 18:10Я думаю, что вы однажды столкнетесь с клиентом, на котором будут нужны десятки / сотни вычислений из разных источников данных и тогда мы поговорим с вами о глубоком сравнении, оптимизации и других инструментах реакта и библиотеках которые отвечают за хранение данных
artemmorozov13 Автор
19.04.2024 18:10У вас возникали такие проблемы? Приходилось отрисовывать жирнейшую логику с множеством списков и фильтров, никаких проблем не было, да были некоторые рендеринги, но на UX это никак не влияло. Мне было бы очень интересно посмотреть пример, когда редакс не справляется
healthxp1
19.04.2024 18:10Да, полностью плюсую комментарию, что не в одном бойлерплейте дело: из-за того, что в Redux мы работаем с единым большим стором с определенного момента начинаются неизбежные проблемы с оптимизацией приложения (чего нет в MobX из-за разницы в реализации библиотек)
И добавлю, что в целом при работе с Redux могут быть различные подходы (например, асинхронщина может реализовываться как через Redux Thunk / Sagas / RxJS + Redux Observable - и в зависимости от выбранного подхода количество кода также будет значительно различаться.
artemmorozov13 Автор
19.04.2024 18:10+1В чем проблема с оптимизацией в Redux при разростании приложения? Не вижу проблем
Alexandroppolus
19.04.2024 18:10Ну вот такой классический кейс: допустим, в вашем примере верстка поста уехала в отдельный компонент Post. При изменении какого-то поля отдельного поста, мобх может обойтись ререндером одного Post, а редукс вынужден дополнительно ререндерить список
gen1lee
19.04.2024 18:10Нет такой проблемы в redux, у вас очень слабое знание этой библиотеки.
gen1lee
19.04.2024 18:10Поясню для любителя минусовать - с нормализацией, если список подписан на список из id, а конкретный пост на объект c id, то список не перерендерится. Но, минусаторы на то и минусаторы, что им все равно.
artemmorozov13 Автор
19.04.2024 18:10Если я вас правильно понял, то для изменения данных в списке постов может быть использован createEntityAdapter, проблем не будет
Alexandroppolus
19.04.2024 18:10+1Да, нормализация данных, я уж и подзабыл) Но тогда это в копилку лишнего бойлерплейта, разве нет? Мы вынуждены нормализовывать, а в мобх можно обойтись просто массивом объектов.
artemmorozov13 Автор
19.04.2024 18:10Ну, как вы будете изменять элемент, если передали только id? Нужно будет нормализовывать
mayorovp
19.04.2024 18:10Очевидно, что без нормализации передавать нужно не id, а сам объект. В чём вы видите сложность изменить свойство объекта?
gen1lee
19.04.2024 18:10передавать объект это типичный говнокод, с которым отвалится deep linking например
Alexandroppolus
19.04.2024 18:10+1В тех случаях, где с передачей объектов возникнут проблемы, можно передавать id. Но вовсе не потому, что инструмент, видите ли, без этого не может нормально работать.
gen1lee
19.04.2024 18:10Передавать в экран объект вместо id это плохо всегда. Между вложенными компонентами допустимо. И да, инструменты тут ни при чем.
Alexandroppolus
19.04.2024 18:10+1Я допускаю, что это плохо в некоторых отдельных случаях. Но по поводу "всегда" интересно увидеть обоснование.
gen1lee
19.04.2024 18:10Экраны должны быть автономны, должна быть возможность открывать любой экран хоть при старте приложения, и чтоб он подкачал все что ему требуется. Это нужно, например, для deep lilnking, и если так не делать, то когда это понадобится придется переписать половину приложения. Так же это показатель, что архитектура приложения удачная, и дает много других бонусов, о которых можно статью отдельную писать.
Alexandroppolus
19.04.2024 18:10Добавлю, что при таком подходе компонент становится более автономным, не завязанным на глобальный стор, и легче тестируемым. Впрочем, это наверно очевидно...
gen1lee
19.04.2024 18:10В redux и без нормализаций хорошо работает, так как при реализации списков элементы списка мемоизируются, и при перерендеринге списка они не перерисовываются если не изменились их данные. Нормализация это скорей для совсем идеального решения.
gen1lee
19.04.2024 18:10У вас слабое знание предмета - библиотеки redux. Там можно делать несколько сторов. И да, у 99.9% приложений никаких проблем с оптимизацией не возникнет даже с одним стором.
gen1lee
19.04.2024 18:10В redux для асинхронищины вообще не обязательно что либо использовать. Просто мало кто об этом знает.
healthxp1
19.04.2024 18:10А кто говорит, что обязательно? Также как и то, что технически невозможно создать несколько сторов?
Скорее здесь был описан наиболее стандартный пример: стор один и он глобален, для взаимодействия с API / других асинхронных операций используется какая-либо библиотека из указанных выше (что как раз таки во многом позволяет сокращать кол-во бойлерплейта, который и оценивается). А насчет перформанса Redux даже его дока не дает однозначного ответа (с одной стороны да, многие приложения не будут сталкиваться с проблемами оптимизации, но в то де время другие библиотеки могут работать эффективнее, без траты времени на кастомизацию и профилирование): https://redux.js.org/faq/performance#how-well-does-redux-scale-in-terms-of-performance-and-architecture
P.S. И да, как грамотно ответил кто-то в комментах, говоря про бойлерплейт, для указанного примера самым лаконичным был бы React Query / SWR)
gen1lee
19.04.2024 18:10Критика боилерплейта redux это почти всегда рассуждения на примерах типа hello world. Вот только во-первых его можно очень неплохо "причесать", а во-вторых - с ростом проекта он не увеличивается, в отличие от MobX, где растет количество связей, появляются нетипичные проблемы, решать которые просто не получится, такие как сериализация, или инициализация большого куска данных из сохраненного состояния. В redux состояние это всегда максимально тупая структура, которую можно целиком стерилизовать и десериализовать при желании, а нетипичные ситуации практически отсутствуют - на все есть продуманное, популярное решение. А для mobx даже появился mobx-state-tree, чтобы решить эту архитектурную проблему.
Наиболее стандартный пример с одним стором потому и стандартный, потому что на практике это отлично работает, и даже не требуется разбивать на сторы, а для оптимизации достаточно грамотное использование и какой нибудь reselect, и только если нужно совсем хорошо, можно обратиться к нормализации (об этом и написано в доке), и созданию дополнительных сторов, и даже использование React.Context для какой нибудь темы. А практика вообще показывает, что в 95% случаев узкое горлышко это как раз не redux, а неоптимальный код на react, и соответственно выбирать технологию нужно изходя из того, какая проще. Redux намного проще, а значит лучше.
DarthVictor
19.04.2024 18:10+1На прошлой неделе впервые поучаствовал в конференции по Frontend, где один из докладчиков, расказывал, как удачно его команда переехала с Redux на Mobx.
Примеры с конференций не очень показательны, даже переписав старое приложение с Redux на Redux можно здорово уменьшить код, просто самим фактом рефакторинга.
Пример в статье сильно маленький и не даёт особого представления о разработке. На таком примере Zustand будет короче обоих вариантов, а SWR или @tanstack/react-query вообще обойдутся парой строк для описания всего взаимодействия с сервером. Причём у последних реализация ещё и будет корректнее.
В реальном приложении будут играть роль такие моменты как:
Удобство композиции сторов. Использование одного действия для изменения нескольких сторов, например.
Удобство взаимодействие c компонентами, насколько библиотека opinionated (в данном контексте насколько она влияет на архитектуру приложения).
Оптимизация быстродействия. "Удобные" селекторы в Redux, например.
Взаиможействие с локальным стейтомЗачеркнул, потому что в React он сделан так, что в любой библиотеке это будет примерно одинаково ху..во.Взаимодействие с библиотеками для работы с сервером вроде SWR и @tanstack/react-query, либо наличие чего-то аналогичного в экосистеме.
Тестируемость.
Простота отладки и логгирования, в том числе в проде.
Взаимодействие с серверным рендерингом.
Удобство работы со сложным асинхронным кодом (всякие саги у Redux и экшены на генераторах у MobX).
-
Реализация шаблона внедрения зависимости, либо взаимодействие со встроенным в React внедрением зависимости через Context.
Modin
19.04.2024 18:10+1Откуда? Какие такие экшены на генераторах в Mobx? Mobx 5 это ванильный js/ts код за исключением, может быть makeAutoObservable в конструкторе класса. Все. После этого делайте хоть на генераторах, хоть на промисах, хоть на async/await, никто не диктует условий
Modin
19.04.2024 18:10+1Конечно, внутри, все устроено сложнее, но это скрыто от пользователя, по факту взаимодействовать придется с почти ванильным js
mayorovp
19.04.2024 18:10Никакое чудо не даст какой-то там библиотеке внедряться в механизм async/await, хоть десять версий выпусти. А внедряться надо, чтобы избегать лишних рендеров.
Так что либо генераторы, либо особый плагин к транспилятору.
markelov69
19.04.2024 18:10+1Вместо тысячи слов и споров, всё максимально наглядно и очевидно для всех.
Вот прям пример с MobX (меньше 100 строк кода), тут есть и глобальное состояние и локальное состояние компонента:
https://codesandbox.io/p/sandbox/adoring-banach-k8m9ss?file=%2Fsrc%2FApp.tsxРеализуйте тоже самое, только используя Redux/RTK и приложите ссылку на codesandbox и сравним все вместе какой код приятнее, с RTK или с MobX. Критерий оценки не кол-во строк кода, а то, насколько код читаем, легко понятен и очевиден.
artalar
19.04.2024 18:10+1А если еще чуть в сторонку посмотреть...
https://github.com/artalar/RTK-entities-basic-example/pull/1/filesnin-jin
19.04.2024 18:10Так всё же более понятно:
import { $mol_wire_solo as mem, $mol_wire_method as act, $mol_wire_sync as sync } from 'mol_wire_lib' import userAPI from './userAPI' export class User extends Object { @mem static all( next?: User[] ) { return next ?? sync( userAPI ).fetchAll().data } @mem static one( id: string ) { return this.all().find( user => user.id === id ) } @act static del( id: string ) { this.all( this.all().filter( user => user.id !== id ) ) } }
А если говорить про редакс, то разница вообще на порядок.
hash_buy_yourself
19.04.2024 18:10Крайне неграмотный подход, в примерах используются разные подходы, например типы в редакс другие, а правильнее использовать такие же если мы хотим сравнить количество кода, в примере с компонентом тоже самое при использовании мобыкс почему то был использован switch case, можно было написать так же как и в примере с редаксом
mayorovp
Только вот на таком простом примере для mobx вообще не требуется отдельный стор. А при усложнении примеров совсем не факт, что оба варианта будут усложняться одннаково.
Кстати, а почему вы в примере с mobx реализовали fetchPostList, а в примере с redux импортировали его из какого-то.api?
artemmorozov13 Автор
Извиняюсь, да нужно было брать запрос котрый написан ниже fetchPostListAsync и типизация через описаный тип выше, поправил
mayorovp
И откуда у обычной функции fetchPostListAsync появились дополнительные свойства? Надеюсь, прототипы функций эта библиотека не ковыряет?