Frontend. Поток данных

Здравствуйте:)

Коротко о чем тут, чтобы вы могли понять нужно ли оно вам или нет.

Тут я описываю то к чему пришел в проектировании глобальных сторов и потока данных в приложении.

Материал может быть полезен как для новичков, так и для более опытных.

Примеры будут на React и Effector, но это не важно, потому что тут важна идея, а не реализация. К тому же это вездебудет примерно одинаково выглядеть.В конце будут так же ссылки на примеры с svelte + effectorи react + redux thunk

Перед тем как это всё начать писать, я изучил похожие подходы и да, они есть.Есть FLUX (там еще Dispatcher), MVI, может еще что-то.

Да, я опять не открыл Америку, но попытаюсь понятно объяснить свой подход и описать его плюсы.

И да, весь код дальше считайте псевдокодом, там могут быть ошибки, я писал его сюда сразу.

А теперь к сути. В чем идея?

Я предлагаю организовать весь поток данных не относящийся к UI таким образом:

3bb0578fe3354219a67d4e70ce9e4b3a.png

  • UI — подписывается на изменения Model и рендерит их.

  • UI — вызывает Action.

  • Model — подписывается на Action.

Что это значит?

  • UI только рендерит данные и вызывает какие-то экшены.

  • Model сама себя обновляет в зависимости от того какой экшен был вызван.

Вариант реализации

Давайте представим такое простое приложение.

Допустим у нас есть:

  1. Форма создания новой задачи

  2. Список задач

Тогда нам нужно иметь, допустим, 3 поля:

  1. Состояние добавления новой задачи (Boolean)

  2. Состояние загрузки задач (Boolean)

  3. Список задач (Array)

Так же нам нужны будут 2 экшена:

  1. Создать новую задачу (createTodo)

  2. Получить список всех задач (getTodos)

И тут начинается самое интересное.

Давайте создадим эти Action-ы.

// /action/todo/createTodo.ts
export const craeteTodo = function (title: string): Promise {
    return fetch(`/api/v1/todo`, { method: 'POST', body: title })
        .then((response) => response.json());
};
// /action/todo/getTodos.ts
export const getTodos = function (): Promise> {
    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(false)
    .on(getTodosEffect, () => true) // Подписываемся на начало выполнения
    .on(getTodosEffect.finally, () => false); // Подписываемся на окончание выполнения

/*
 * todoAdding - состояние добавления новой задачи
 * Логика работы такая же
 */
export const todoAdding = createStore(false)
    .on(addTodoEffect, () => true)
    .on(addTodoEffect.finally, () => false);

/*
 * todoItems - список задач
 * Логика работы такая же, но тут мы уже работаем с состоянием успешного завершения.
 * В payload.result будет храниться результат вернувшийся из нашего action-а
 * который просто просто разворачиваем в наш список
 */
export const todoItems = createStore>([])
    .on(addTodoEffect.done, (state, payload) => [ ...state, payload.result ])
    .on(getTodosEffect.done, (state, payload) => [ ...state, ...payload.result ]);

А теперь давайте напишем простенький UI.

Нам нужны будут 2 компонента. (да, можно разбить на кучу разных, но тут это не важно, по этому опускаем)

  1. Форма добавления новой задачи

  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(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 (
        
); });

Давайте разберем этот компонент и его поведение.

Изначально он рендерится и, предположим, что состояние todoAddingбудет false. Тогда элементы формы не будет задизейблены и мы сможем ввести что хотим и создать задачу.

  1. Мы вводим в inputновую задачу и отправляем форму.

  2. При отправке формы вызывается addTodoEffect.

  3. В модели по подписке на addTodoEffectзначение todoAddingизменится на true

  4. Наш компонент начнет перерендер с новым значением todoAddingи элементы формы заблокируются.

  5. После завершения создания новой задачи, по подписке на addTodoEffect.finallyзначение todoAddingпоменяется на false

  6. Ререндер со значением todoAddingfalse, форма опять доступна.

Вернемся к тому что я писал в начале.

  • 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 ;
    }

    // Если задач нет
    if (items.length === 0) {
        return 'Задач нет';
    }

    return (
        

Список задач

{ // Просто рендерим список задач из нашего стора items.map((item) => (

{ item.title }

)) }
); });

Отлично, теперь у нас так же есть компонент который просто рендерит список задач.

Можно было бы внутрь него добавить

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 (
        
{ /* С помощью этой кнопки будем загружать задачи */ }
); });

Теперь давайте представим как этот компонент будет выглядеть при инициализации приложения, а следовательно, допустим, первом рендере.

  • Сверху будет поле ввода и кнопка создания (форма создания новой задачи)

  • Дальше текст «Задач нет»

  • Дальше кнопка «Загрузить список»

Теперь давайте подумаем что будет если мы нажмем на кнопку «Загрузить список»:

  1. Выполняется эффект getTodosEffect

  2. По подписке на этот эффект todoLoadingпереходит в true

  3. В появляется

  4. Кнопка «Загрузить список» блокируется

  5. Отправляется запрос на сервер

  6. Приходит ответ с сервера с задачами

  7. Экшен завершается успешно

  8. По подписке на getTodosEffect.finallytodoLoadingпереходит обратно в false

  9. По подписке на getTodosEffect.donetodoItemsв конец себя вставляет загруженные задачи

  10. Компонент рендерит список

  11. Кнопка «Загрузить список» больше не блокируется

Мы из UI не меняем никаких параметров, ничего вообще. Мы только рендерим данные из модели и вызываем экшены.

В итоге мы имеем:

  • Множество разных Action-ов, которые просто выполняют какие-то свои задачи. Мы можем их даже из проекта в проект перетаскивать. Хоть он будет на svelte + effector хоть на react + redux.

  • Model которая хранит данные и в зависимости от выполняемых действия меняет свое состояние.

  • UI который просто рендерит данные и выполняет экшены.

Какие у этого подхода есть плюсы?

  1. Все изменения стора контролируются его подписками на эффекты. То есть мы не можем никак просто поменять стор как хотим из UI.

  2. Понятный и простой поток данных во всем приложении.

Минусы? Пока не обнаружены.

В целом, можно и многие состояния 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-ами будет работать тоже удобно.

React + Redux 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;
        });
    },
});

Ну и последний репозиторий где я только начал переписывать этот же проект, но там уже есть аутентификация, её достаточно для понимания того, что я имел в виду

Svelte + Effector

Вот пример модели аутентификации оттуда:

export const loginEffect        = createEffect(loginAction);
export const registrationEffect = createEffect(registrationAction);
export const logoutEffect       = createEffect(logoutAction);
export const refreshEffect      = createEffect(refreshAuthAction);

export const authPending = createStore(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(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(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

Спасибо за внимание:)

Habrahabr.ru прочитано 1751 раз