Здравствуйте :)
Коротко о чем тут, чтобы вы могли понять нужно ли оно вам или нет.
Тут я описываю то к чему пришел в проектировании глобальных сторов и потока данных в приложении.
Материал может быть полезен как для новичков, так и для более опытных.
Примеры будут на 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)
 - alexMolex27.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 просто рендерит данные и вызывает экшены. Без отслеживания состояния этих экшенов и всего такого. (да, для оптимизации иногда будет лучше отслеживать экшены которые вызываются, но это достаточно редкие случаи, от которых лучше отказаться, если есть возможность)
 
 - kellas27.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, из пуш уведомления, из какого-то евента.. Он вызвался - по подписке обновились данные - рендер. Всё. Это вся идея. Удобно. Понятно. Просто.  - kellas27.06.2024 07:33- Cпасибо за подробный ответ! Я понял что нужно все что я описал реализовывать в модели, а статья у вас не про её внутренности, согласен. 
 Мне тяжело вот так в уме представить всё это, я не видел подобных реализаций и вот показалось что это превратится в неповоротливого монстра если добавить всё что вы описали для работы с данными, будет огромное количество экшенов, частично или полностью дублирующих друг друга, раздутый стэйт и т п. но да - наверно это можно сделать более-менее красиво всё
 
 
 
           
 

nin-jin
Спасибо за вредные советы.
VanyaMate Автор
Здравствуйте.
Это не реклама эффектора) И я не говорю, что используйте его) Причина почему примеры на нем - они простые и понятные. Я лишь упомянул, что с его помощью это легко реализуется. Есть другие инструменты.
Всё что мне нужно от эффектора: createStore, createEffect. Всё. Для реакта еще из effector-react импортировать хук useUnit.
Но спасибо за видос, гляну) Полезно, думаю, будет)