Frontend. Data stream

Hello 🙂

Briefly about what is here so that you can understand whether you need it or not.

Here I describe what I came up with in designing global stores and data flow in the application.

The material can be useful for both beginners and more experienced ones.

The examples will be in React and Effector, but this is not important, because the idea is important here, not the implementation. In addition, it will look approximately the same everywhere. At the end there will also be links to examples with svelte + effector And react + redux thunk

Before I started writing all this, I studied similar approaches and yes, they exist. There is FLUX (there is also Dispatcher), MVI, maybe something else.

Yes, I haven’t discovered America again, but I’ll try to clearly explain my approach and describe its advantages.

And yes, consider all the code below to be pseudocode, there may be errors, I wrote it here right away.


Now to the point. What's the idea?

I suggest organizing all non-UI data flow this way:

  • UI – subscribes to Model changes and renders them.

  • UI – calls Action.

  • Model – subscribes to Action.

What does it mean?

  • UI only renders data and calls some actions.

  • The Model updates itself depending on what action was called.

Implementation option

Let's imagine such a simple application.

Let's say we have:

  1. New task creation form

  2. Task list

Then we need to have, say, 3 fields:

  1. Status of adding a new task (Boolean)

  2. Task loading status (Boolean)

  3. Task list (Array<Todo>)

We will also need 2 actions:

  1. Create a new task (createTodo)

  2. Get a list of all tasks (getTodos)

And this is where the fun begins.

Let's create these Actions.

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

Great. As you can see, these are just ordinary functions, everything is simple.

Now let's create a 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 ]);

Now let's write a simple UI.

We will need 2 components. (yes, you can break it down into a bunch of different ones, but that’s not important here, so we’ll omit it)

  1. Form for adding a new task

  2. Task list

Let's create a form to add a new task

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

Let's take a look at this component and its behavior.

Initially it is rendered and let's assume that the state todoAdding will false. Then the form elements will not be disabled and we will be able to enter what we want and create a task.

  1. We are entering into input a new task and submit the form.

  2. When the form is submitted, it is called addTodoEffect.

  3. In the subscription model addTodoEffect meaning todoAdding will change to true

  4. Our component will start re-rendering with the new value todoAdding and the form elements will be locked.

  5. After completing the creation of a new task, by subscribing to addTodoEffect.finally meaning todoAdding will change to false

  6. Render with value todoAdding falsethe form is available again.

Let's go back to what I wrote at the beginning.

  • UI – subscribes to Model changes and renders them.

  • UI – calls Action.

  • Model – subscribes to Action.

As you can see, everything is very easy and simple (I hope).

Now let's create a second component in the same way to display the task list.

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

Great, now we also have a component that simply renders a list of tasks.

It would be possible to add it inside

useEffect(() => {
    getTodosEffect();
}, []);

and everything would work great, but we will call it in a completely different place.

Let's create another one root component to show how great it all works

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

Now let's imagine how this component will look when the application is initialized, and therefore, for example, the first render.

  • At the top there will be an input field and a create button (a form for creating a new task)

  • Then the text “No tasks”

  • Next is the “Download list” button

Now let's think about what will happen if we click on the “Download list” button:

  1. Effect running getTodosEffect

  2. Subscribe to this effect todoLoading goes into true

  3. IN <TodoList/> appears <Loader/>

  4. The “Load list” button is blocked

  5. A request is sent to the server

  6. A response comes from the server with tasks

  7. The action completes successfully

  8. By subscription to getTodosEffect.finally todoLoading goes back to false

  9. By subscription to getTodosEffect.done todoItems inserts loaded tasks at the end of itself

  10. Component <TodoList/> renders a list

  11. The “Load List” button is no longer blocked

We from the UI do not change any parameters, nothing at all. We just render data from the model and call actions.

As a result we have:

  • Many different Actions that simply perform some of their own tasks. We can even drag them from project to project. At least he will be on svelte + effector at least for react + redux.

  • Model which stores data and changes its state depending on the actions performed.

  • A UI that simply renders data and executes actions.

What are the advantages of this approach?

  1. All changes to the store are controlled by its effects subscriptions. That is, we cannot simply change the store as we want from the UI.

  2. Clear and simple data flow throughout the application.

Minuses? Not discovered yet.

In general, you can also store many UI states in the same stores and change them through other actions, but I haven’t done this yet and I don’t know how convenient it will be or even necessary. But, as an option, sometimes, some, it is possible. I can imagine such cases.

It doesn't matter what folder structure you choose.

I do something like this:

but it is not important. The main thing is to just think about your data flow and visualize it in your head, and with this approach it is very easy.

Tools

As a UI, there are a lot of things that will work here. The obvious options are React and Svelte. Unfortunately, I don’t know about others, but I think it will be +- the same everywhere.

As a Model – here from what I tried and what I am sure of – Redux, Effector. In zustand there seem to be no such subscriptions.. In mobx too.. But this does not mean that this approach cannot be implemented on them..

Well, for actions, use whatever you want, it's just javascript


Also, before writing all this, I tested it and it turned out to be several repositories. If anyone is interested in seeing more examples, please see the links below.


Small identical todo


Some kind of social network

It was when I was doing this project that I came to this approach and it is not all done in this style. I remade it, but only partially. But you can still see how this can be done in Redux using Thunks.

In this project, a lot was redone with extraReducers and Thunks, but I didn’t highlight the actions, they are right inside the Thunks. As I understand it, the signature is saved as in effector, so it will also be convenient to work with thunks.

React + Redux Thunk

Models are located here: /src/app/redux/slices/[name]/slice

Thunk(Action) are here: /src/app/redux/slices/[name]/thunk

Here is an example of a model using redux and thunks.

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

Well, the last repository where I just started rewriting the same project, but there is already authentication there, it’s enough to understand what I meant

Svelte+Effector

Here's an example authentication model from there:

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

I will also be glad to have questions, criticism, additions, etc. Maybe you have been using this approach or a similar one for a long time and there are some not obvious pitfalls, I will be glad if you share it in the comments.

You can also write a private message in tg: https://t.me/VanyaMate

Thank you for your attention 🙂

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *