Здравствуйте :)
Коротко о чем тут, чтобы вы могли понять нужно ли оно вам или нет.
Тут я описываю то к чему пришел в проектировании глобальных сторов и потока данных в приложении.
Материал может быть полезен как для новичков, так и для более опытных.
Примеры будут на React и Effector, но это не важно, потому что тут важна идея, а не реализация. К тому же это вездебудет примерно одинаково выглядеть.В конце будут так же ссылки на примеры с svelte + effector
и react + redux thunk
Перед тем как это всё начать писать, я изучил похожие подходы и да, они есть.Есть FLUX (там еще Dispatcher), MVI, может еще что-то.
Да, я опять не открыл Америку, но попытаюсь понятно объяснить свой подход и описать его плюсы.
И да, весь код дальше считайте псевдокодом, там могут быть ошибки, я писал его сюда сразу.
А теперь к сути. В чем идея?
Я предлагаю организовать весь поток данных не относящийся к UI таким образом:
UI - подписывается на изменения Model и рендерит их.
UI - вызывает Action.
Model - подписывается на Action.
Что это значит?
UI только рендерит данные и вызывает какие-то экшены.
Model сама себя обновляет в зависимости от того какой экшен был вызван.
Вариант реализации
Давайте представим такое простое приложение.
Допустим у нас есть:
Форма создания новой задачи
Список задач
Тогда нам нужно иметь, допустим, 3 поля:
Состояние добавления новой задачи (
Boolean
)Состояние загрузки задач (
Boolean
)Список задач (
Array<Todo>
)
Так же нам нужны будут 2 экшена:
Создать новую задачу (
createTodo
)Получить список всех задач (
getTodos
)
И тут начинается самое интересное.
Давайте создадим эти Action-ы.
// /action/todo/createTodo.ts
export const craeteTodo = function (title: string): Promise<Todo> {
return fetch(`/api/v1/todo`, { method: 'POST', body: title })
.then((response) => response.json());
};
// /action/todo/getTodos.ts
export const getTodos = function (): Promise<Array<Todo>> {
return fetch(`/api/v1/todo`, { method: 'GET' })
.then((response) => response.json());
Отлично. Как вы видите это просто обычные функции, всё просто.
Теперь давайте создадим Model.
// /model/todo/todo.model.ts
/*
* Для того чтобы связать action-ы с нашими сторами
* мы будем использовать createEffect из effector.
* Все сигнатуры фунций останутся, но теперь мы можем подписаться на них
*/
export const createTodoEffect = createEffect(craeteTodo);
export const getTodosEffect = createEffect(getTodos);
/*
* todoLoading - состояние загрузки списка задач
* Что тут происходит?
* Мы подписываемся на эффекты которые только что создали и:
* Когда мы вызовем getTotosEffect - состояние изменится на true
* Когда getTodosEffect выполнится - состояние поменяется на false
*
* Таким образом можно подписываться на множество разных экшенов
* или на один и тот же, но использовать разные состояния
* (done, fail, finally, ...)
*/
export const todoLoading = createStore<boolean>(false)
.on(getTodosEffect, () => true) // Подписываемся на начало выполнения
.on(getTodosEffect.finally, () => false); // Подписываемся на окончание выполнения
/*
* todoAdding - состояние добавления новой задачи
* Логика работы такая же
*/
export const todoAdding = createStore<boolean>(false)
.on(addTodoEffect, () => true)
.on(addTodoEffect.finally, () => false);
/*
* todoItems - список задач
* Логика работы такая же, но тут мы уже работаем с состоянием успешного завершения.
* В payload.result будет храниться результат вернувшийся из нашего action-а
* который просто просто разворачиваем в наш список
*/
export const todoItems = createStore<Array<Todo>>([])
.on(addTodoEffect.done, (state, payload) => [ ...state, payload.result ])
.on(getTodosEffect.done, (state, payload) => [ ...state, ...payload.result ]);
А теперь давайте напишем простенький UI.
Нам нужны будут 2 компонента. (да, можно разбить на кучу разных, но тут это не важно, по этому опускаем)
Форма добавления новой задачи
Список задач
Давайте создадим форму добавления новой задачи
// /ui/widget/todo/AddTodoForm.tsx
import { FC, memo } from 'react';
import { useUnit } from 'effector-react';
import { todoAdding } from '@/model/todo/todo.model';
export const AddTodoForm: FC = memo(function AddTodoForm () {
// Для начала получим состояние добавления задачи с помощью useUnit
const adding = useUnit(todoAdding);
// Так же создам ref для хранения ссылки на input для получения value
const inputRef = useRef<HTMLInputElement | null>(null);
// Ну и функцию которая сработает при отправке формы
const onSubmit = function (event: FormEvent) {
event.preventDefault();
// Проверяем что есть инпут, значение, и новая задача не создается в данный момент
if (input.current && input.current.value && !adding) {
// И просто вызываем наш эффект как экшен.
addTodoEffect(input.current.value)
.then(() => {
if (input.current) {
input.current.value = '';
}
});
}
};
return (
<form onSubmit={ onSubmit }>
<input ref={ inputRef } disabled={ adding }/>
<button type="submit" disabled={ adding }>Создать</button>
</form>
);
});
Давайте разберем этот компонент и его поведение.
Изначально он рендерится и, предположим, что состояние todoAdding
будет false
. Тогда элементы формы не будет задизейблены и мы сможем ввести что хотим и создать задачу.
Мы вводим в
input
новую задачу и отправляем форму.При отправке формы вызывается
addTodoEffect
.В модели по подписке на
addTodoEffect
значениеtodoAdding
изменится наtrue
Наш компонент начнет перерендер с новым значением
todoAdding
и элементы формы заблокируются.После завершения создания новой задачи, по подписке на
addTodoEffect.finally
значениеtodoAdding
поменяется наfalse
Ререндер со значением
todoAdding
-false
, форма опять доступна.
Вернемся к тому что я писал в начале.
UI - подписывается на изменения Model и рендерит их.
UI - вызывает Action.
Model - подписывается на Action.
Как вы видите всё очень легко и просто (надеюсь).
Теперь давайте, точно так же создадим второй компонент, для отображения списка задач.
// /ui/widget/todo/TodoList.tsx
import { FC, memo } from 'react';
import { useUnit } from 'effector-react';
import { todoAdding } from '@/model/todo/todo.model';
export const TodoList: FC = memo(function TodoList () {
// Получим состояние загрузки и список задач
const [ loading, items ] = useUnit([ todoLoading, todoItems ]);
// Давайте если у нас загрузка (todoLoading === true) - покажем лоадер
if (loading) {
return <Loader/>;
}
// Если задач нет
if (items.length === 0) {
return 'Задач нет';
}
return (
<section>
<h1>Список задач</h1>
{
// Просто рендерим список задач из нашего стора
items.map((item) => (
<article key={ item.id }>
<h2>{ item.title }</h2>
</article>
))
}
</section>
);
});
Отлично, теперь у нас так же есть компонент который просто рендерит список задач.
Можно было бы внутрь него добавить
useEffect(() => {
getTodosEffect();
}, []);
и всё бы отлично работало, но мы вызовем это совсем в другом месте.
Давайте создадим еще один root
компонент для того, чтобы показать на сколько это всё классно работает
import { FC, memo } from 'react';
import { useUnit } from 'effector-react';
import { todoAdding, getTodosEffect } from '@/model/todo/todo.model';
export const TodosApp: FC = memo(function TodosApp (props) {
const loading = useUnit(todoLoading);
return (
<div>
<AddTodoForm/>
<TodoList/>
{ /* С помощью этой кнопки будем загружать задачи */ }
<button onClick={ getTodosEffect } disabled={ loading }>
Загрузить список
</button>
</div>
);
});
Теперь давайте представим как этот компонент будет выглядеть при инициализации приложения, а следовательно, допустим,первом рендере.
Сверху будет поле ввода и кнопка создания (форма создания новой задачи)
Дальше текст "Задач нет"
Дальше кнопка "Загрузить список"
Теперь давайте подумаем что будет если мы нажмем на кнопку "Загрузить список":
Выполняется эффект
getTodosEffect
По подписке на этот эффект
todoLoading
переходит вtrue
В
<TodoList/>
появляется<Loader/>
Кнопка "Загрузить список" блокируется
Отправляется запрос на сервер
Приходит ответ с сервера с задачами
Экшен завершается успешно
По подписке на
getTodosEffect.finally
-todoLoading
переходит обратно вfalse
По подписке на
getTodosEffect.done
-todoItems
в конец себя вставляет загруженные задачиКомпонент
<TodoList/>
рендерит списокКнопка "Загрузить список" больше не блокируется
Мы из UI не меняем никаких параметров, ничего вообще. Мы только рендерим данные из модели и вызываем экшены.
В итоге мы имеем:
Множество разных Action-ов, которые просто выполняют какие-то свои задачи. Мы можем их даже из проекта в проект перетаскивать. Хоть он будет на
svelte + effector
хоть наreact + redux
.Model которая хранит данные и в зависимости от выполняемых действия меняет свое состояние.
UI который просто рендерит данные и выполняет экшены.
Какие у этого подхода есть плюсы?
Все изменения стора контролируются его подписками на эффекты. То есть мы не можем никак просто поменять стор как хотим из UI.
Понятный и простой поток данных во всем приложении.
Минусы? Пока не обнаружены.
В целом, можно и многие состояния UI так же хранить в таких же сторах и изменять их через другие action-ы, но я так еще не делал и не знаю на сколько это будет удобно и вообще нужно. Но, как вариант, иногда, некоторые, можно. Представить случаи такие могу.
Какую структуру папок вы выберете - не важно.
Я делаю примерно так:
-
/src
-
/ui
/shared
/entity
....
/action
/model
...
-
но это не важно. Главное просто думать о вашем потоке данных и представлять его в голове, а с таким подходом это очень легко.
Инструменты
В качестве UI - тут много что подойдет. Очевидные варианты React, Svelte. К сожалению насчет других не знаю, но думаю везде будет +- одно и тоже.
В качестве Model - тут из того что я пробовал и в чем уверен - Redux, Effector. В zustand вроде таких подписок нет.. В mobx тоже.. Но это не значит, что этот подход на них не реализовать..
Ну а для экшенов используйте что хотите, это просто javascript
Так же перед тем как это всё написать - я это тестировал и получилось несколько репозиториев. Кому интересно посмотреть больше примеров - пожалуйста, ссылки ниже.
Маленькие одинаковые todo
Что-то типа социальной сети
Именно когда я делал этот проект - я дошел до этого подхода и он не весь выполнен в таком стиле.Я его переделывал, но лишь частично. Но вы все равно можете посмотреть как это можно сделать на Redux через Thunk-и.
В этом проекте многое переделывалось на extraReducers и Thunk-и, но выделять экшены я не стал, они прям внутри thunk-ов. Как я понимаю, сигнатура сохраняется как и в effector, по этому с thunk-ами будет работать тоже удобно.
Модели лежат тут: /src/app/redux/slices/[name]/slice
Thunk(Action) лежат тут: /src/app/redux/slices/[name]/thunk
Вот пример модели на redux и thunk-ах.
const initialState: AuthSchema = {
isPending: false,
error : null,
user : null,
};
export const authSlice = createSlice({
name : 'auth',
initialState : initialState,
reducers : {},
extraReducers: (builder) => {
// authByUsername
builder.addCase(authByUsername.fulfilled, (state, action) => {
state.isPending = false;
state.error = null;
state.user = action.payload ?? null;
});
builder.addCase(authByUsername.pending, (state) => {
state.isPending = true;
state.error = null;
state.user = null;
});
builder.addCase(authByUsername.rejected, (state, action) => {
state.isPending = false;
state.error = action.payload;
state.user = null;
});
// authByTokens
builder.addCase(authByTokens.fulfilled, (state, action) => {
state.isPending = false;
state.error = null;
state.user = action.payload ?? null;
});
builder.addCase(authByTokens.pending, (state) => {
state.isPending = true;
state.error = null;
state.user = null;
});
builder.addCase(authByTokens.rejected, (state, action) => {
state.isPending = false;
state.error = action.payload;
state.user = null;
});
// logout
builder.addCase(logout.fulfilled, (state) => {
state.isPending = false;
state.error = null;
state.user = null;
});
},
});
Ну и последний репозиторий где я только начал переписывать этот же проект, но там уже есть аутентификация, её достаточно для понимания того, что я имел в виду
Вот пример модели аутентификации оттуда:
export const loginEffect = createEffect(loginAction);
export const registrationEffect = createEffect(registrationAction);
export const logoutEffect = createEffect(logoutAction);
export const refreshEffect = createEffect(refreshAuthAction);
export const authPending = createStore<boolean>(false)
.on(loginEffect, () => true)
.on(registrationEffect, () => true)
.on(logoutEffect, () => true)
.on(refreshEffect, () => true)
.on(loginEffect.finally, () => false)
.on(registrationEffect.finally, () => false)
.on(logoutEffect.finally, () => false)
.on(refreshEffect.finally, () => false);
export const authError = createStore<DomainServiceResponseError | null>(null)
.on(loginEffect.fail, (_, payload) => returnValidErrors(payload.error))
.on(registrationEffect.fail, (_, payload) => returnValidErrors(payload.error))
.on(refreshEffect.fail, (_, payload) => returnValidErrors(payload.error));
export const authData = createStore<DomainUser | null>(null)
.on(loginEffect, () => null)
.on(loginEffect.done, (_, payload) => payload.result ?? null)
.on(registrationEffect, () => null)
.on(registrationEffect.done, (_, payload) => payload.result ?? null)
.on(logoutEffect.finally, () => null)
.on(refreshEffect.done, (_, payload) => payload.result ?? null);
Так же буду рад вопросам, критике, дополнениям итд. Может вы уже давно используете такой подход или похожий и есть какие-то не очевидные подводные камни, буду рад, если поделитесь в комментариях.
Так же можете написать в личку в tg: https://t.me/VanyaMate
Спасибо за внимание :)
Комментарии (10)
alexMolex
27.06.2024 07:33Зачем в примере с effecror делать такой финт ушами с добавлением состояния запроса в стор, когда у эффектов есть состояние pending?
VanyaMate Автор
27.06.2024 07:33Здравствуйте.
1. Я не знаю эффектор так хорошо. В первую очередь я взял его потому что на нем можно легко и быстро реализовать то что я имел ввиду, и я взял только тот функционал который мне необходим (createStore, createEffect), а для этого "pending" не нужен.
2. То как эта идея будет реализована - не важно, это уже относится к реализации, а я предлагаю абстракцию, поток данных. Так же отслеживание конкретного эффекта выбивается из идеи.
3. Подписываться на конкретный эффект, в каких то случаях, действительно может быть лучше. В том же редаксе, можно сделать dispatch.unwrap и получить промис, состояние которого можно отслеживать. Но, допустим я решил так же делать todoAdding в true когда у меня вызывается эффект getTodos. Тогда мне нужно будет идти в тот(те) компонент(ы) где я отслеживаю этот effect и менять там что-то.. А это уже именно то от чего я и хочу полностью избавиться..
С todoAdding плохой пример, потому что это буквально стор для добавления новой задачи, и, скорее всего, у нас будет только один такой эффект который будет добавлять задачу, тут с вами согласен.
Я пытался создать максимально простые примеры содержащие только суть моей идеи. UI просто рендерит данные и вызывает экшены. Без отслеживания состояния этих экшенов и всего такого. (да, для оптимизации иногда будет лучше отслеживать экшены которые вызываются, но это достаточно редкие случаи, от которых лучше отказаться, если есть возможность)
kellas
27.06.2024 07:33+1Фронтендеры с этими многочисленными стейт-менеджерами из пустого в порожнее уже лет 15 переливают... и везде в примерах плохо работающая тудушка...
Минусы? Пока не обнаружены.
Минус вашего и многочисленных других подходов например в отсутствии синхронизации стейта между вкладками. Открыл пользователь пару вкладок с приложением - в одной например задачу добавил в тудушку - закрыл - в соседней вкладке все по старому. Если где-то какие-то данные регулярно обновляются тащатся с сервера через long-pooling например - то сколько вкладок открыто - столько запросов и будет отправляться. Какой-то недоглобальный стейт получается. Тут sharedWorker в помощь и слушатели изменения localStorage.
Данные скорее всего одни и те же вытаскиваются по сто раз, не предусмотрено никакого кэширования. А что если еще и данных прям много? Нужна поддержка OPFS или может indexedDB. И как чистить стейт в таком случае от данных которые уже не нужны / не рендерятся больше?
Нет поддержки offline-mode - что если я хочу сидя в самолете набросать себе задач в туду-лист и чтобы они потом когда появится интернет отправились на сервер, а пока я бы мог их редактировать, может что-то бы уже отметил как выполненное пока летел.
Ну и пуш данных с сервера - чтобы если я с телефона что-то добавил - то оно в открытом сайте на компе отобразилось, опять же одного только сообщения через websocket тут не достаточно - крышка ноута то закрыта и он спит - нужно чтобы когда я открою ноут - сайт полез за обновлениями, а заодно отправил свои(ну те, из самолета).
Вот это всё сложные вопросы, не ясно как это лучше организовать, решений хороших и доступных просто нет, что-то платное(rxdb) или c vendor-lock(firebase) и еще с кучей своих ограничений.
И если это все внедрять, то есть делать просто нормальное приложение которое не вводит в ступор пользователя, все подобные подходы превращаются в запутанного монстра, они не масштабируются нормально до такого уровня. А если не делать - то простите, но просто забрать фетчем json с сервака и не запутаться в том в какую переменную его засунул ума много не надо так что и проблем требующих какого-то решения и особенного подхода тоже нет.
То есть основной минус - невозможность масштабировать данный подход до уровня нормального приложения с синхронизацией большим объемом данных итп.VanyaMate Автор
27.06.2024 07:33+1Здравствуйте.
Я говорил о другом и этот подход никак с этими проблемами не связан, а писал я про взаимодействие UI, Model, Action. Всё. То есть то есть UI просто рендерит и вызывает эффекты. А модель сама себя обновляет подписками на эффекты.Я просто предложил некую абстракцию на этом уровне и не дальше. Как это будет реализовано - это уже не важно, главное - принципы.
Но давайте рассмотрим все проблемы которые вы описали в рамках самого простого примера (плохо работающей тудушки).Минус вашего и многочисленных других подходов например в отсутствии синхронизации стейта между вкладками
Я скажу больше. Тут об этом и других задачах - даже ни слова. Так что же с этим делать? Да куча есть всего. Как вы правильно написали - стореджи, воркеры, броадкасты, постпесседжи и что угодно.
Давайте представим, что у нас есть только хранилище статуса добавления задачи.
И в одной из вкладок мы начали добавлять задачу, пошел процесс. Другие вкладки про это ничего не знают, согласен.
Мы берем и из вкладки которая начала процесс - отправляем через, например, броадкаст это уведомление.
Другие вкладки получают это сообщение, понимают что это такое и выполняют какой-то свой экшен, на который подписана модель. И обновляет свое состояние.
// Модель во всех вкладках export const todoAdding = createStore(false) .on(addTodoEffect, () => true) .on(addTodoEffect.finally, () => false);
Как мы свяжем первичный экшен с отправкой уведомления - не важно. Мы для это можем например написать свой интерфейс или сделать обертку над экшеном или над эффектом итд. Опять же, это уже детали реализации.
Но в момент вызова effect-а у нас будет отправляться postMessage всем слушателям с типом сообщения и данными.
На другом конце провода (в другой вкладке) ловится это сообщение, берется тип сообщения и вызывается соответсвующий effect с этим payload. На этот экшен была подписана модель - модель обновляется.
Вот у нас есть общение между вкладками которое соответствует тому что я писал. Модель сама себя обновляет подпиской на эффекты. А UI просто отрендерит новые данные.
------------
Нет поддержки offline-mode
Ну тогда заменяем броадкаст на локалсторейдж и вот у нас есть локальное хранилище.
Разделяем экшены на 2. Один будет просто сохранять в LS, другой отправлять запрос на сервер.
Пихаем их в один, но на "запрос" делаем условие, что мы онлайн.
И вот мы получили поддержку offline. Так же понадобится сделать синхронизацию с сервером при появлении интернета.
Делаем отдельный экшен, который будет это делать. Брать данные из LS и отправлять на сервер. Если мы захотим - еще подпишемся и на него и будем тоже как-то показывать.
------------
Ну и пуш данных с сервера.
В примере с "социальной" сетью это даже реализовано. Есть SSE соединение с сервером через который идут все уведомления и о моих действиях и о действиях других пользователей (сообщения для меня, добавления в друзья итд) и я просто подписываюсь на определенные уведомления и вызываю соответствующие эффекты на которые подписана модель. Модель обновляет себя -> UI просто себя рендерит. Всё.
Вот вам как это было сделано у меня (хоть это и было сделано временно, но суть отражает)
Вызов thunk-ов: https://github.com/VanyaMate/product/blob/master/src/features/notification/hooks/useFriendsStoreUpdaterByNotifications.ts
Модель: https://github.com/VanyaMate/product/blob/master/src/app/redux/slices/friends/slice/friends.slice.ts
Приходит уведомление -> вызывается определенный thunk -> модель обновляет себя из-за подписки -> UI рендерит.
------------
И все эти задачи сами по себе не связаны с тем что я описывал в статье. К сожалению, видимо, плохо её написал и не очень много людей поняло что я имел ввиду вообще, судя по комментариям.
Тут не про эффектор, не про реакт, не про то как вам реализовать это всё. Тут принципы и абстракция. Всё.
Я вообще свой стор написал из 3-х функций
store
,effect
исombine
. Больше и не нужно.UI - рендерит
Model - сама себя обновляет
Action - просто выполняют какую то задачу
UI - не меняет стор, не вызывает редюсеры, а просто рендерит и вызывает action-ы (через эффекты). Эффекты это связь экшена и модели.Почему я показал на react? Просто он самый популярный и очень понятный код.
Почему эффектор? Не такой популярный, но очень понятный код.
Почему плохо работающая тудушка? Очень простой пример.
------------
Просто попробуйте представить себе любой атомарный компонент (widget).
Вся его задача сводится к рендеру данных и вызову какого либо эффекта. Всё.
Вызываем действия, которые просто что-то делают. Например создают новую задачу. Как это будет сделано - не важно. Локал сторейдж или fetch или комбинация этих действий.. Мы просто подписываемся на начало этого действия и конец, с ожиданием того, что нам вернется новая задача которую модель сама в себя вставит, а UI получит автоматически новые данные и отрендерит их. Не важно где был вызван этот эффект. Через UI, из пуш уведомления, из какого-то евента.. Он вызвался - по подписке обновились данные - рендер. Всё. Это вся идея. Удобно. Понятно. Просто.
kellas
27.06.2024 07:33Cпасибо за подробный ответ! Я понял что нужно все что я описал реализовывать в модели, а статья у вас не про её внутренности, согласен.
Мне тяжело вот так в уме представить всё это, я не видел подобных реализаций и вот показалось что это превратится в неповоротливого монстра если добавить всё что вы описали для работы с данными, будет огромное количество экшенов, частично или полностью дублирующих друг друга, раздутый стэйт и т п. но да - наверно это можно сделать более-менее красиво всё
nin-jin
Спасибо за вредные советы.
VanyaMate Автор
Здравствуйте.
Это не реклама эффектора) И я не говорю, что используйте его) Причина почему примеры на нем - они простые и понятные. Я лишь упомянул, что с его помощью это легко реализуется. Есть другие инструменты.
Всё что мне нужно от эффектора: createStore, createEffect. Всё. Для реакта еще из effector-react импортировать хук useUnit.
Но спасибо за видос, гляну) Полезно, думаю, будет)