Заменяем Redux c помощью Observables и React Hooks

Автор оригинала: Simon Trény
  • Перевод
  • Tutorial

Управление состоянием — одна из важнейших задач, решаемых в разработке на React. Было создано множество инструментов в помощь разработчикам для решения этой задачи. Наиболее популярным инструментом является Redux — небольшая библиотека, созданная Дэном Абрамовым, и предназначенная помочь разработчикам использовать паттерн проектирования Flux в их приложениях. В этой статье мы разберем, действительно ли нам нужен Redux, и посмотрим, как мы можем заменить его более простым подходом, в основе которого лежат Observable и React Hooks.


Зачем нам вообще нужен Redux?


Redux так часто ассоциируется с React, что многие разработчики используют его, не задумываясь зачем им нужен именно Redux. React позволяет легко синхронизировать компонент и его состояние с помощью setState() / useState(). Но все становится сложнее, как только состояние начинает использоваться сразу несколькими компонентами. Самое очевидное решение совместного использования общего состояния между несколькими компонентами — это переместить его (состояние) к их общему родителю. Но такое решение «в лоб» очень быстро может привести к сложностям: если компоненты находятся далеко друг от друга в иерархии компонентов, то для обновления общего состояния потребуется множество пробрассываний через свойства компонентов. React Context может помочь уменьшить количество пробрассываний, но объявление нового контекста каждый раз, когда какое-либо состояние начинает использоваться совместно с еще одним компонентом, потребует все больших усилий и в конце концов может привести к ошибкам.


Redux решает эти проблемы, представляя объект Store, который содержит целиком все состояние приложения. В компоненты, которым требуется доступ к состоянию, этот Store внедряется с помощью функции connect. Также эта функция гарантирует, что при изменении состояния все компоненты, зависящие от него, будут перерисованы. Наконец, чтобы изменить состояние, компоненты должны отправлять action, которые запускают reducer для вычисления нового измененного состояния.



Когда в первый раз понял концепции Redux


Что не так с Redux?


Впервые прочитав официальный туториал по Redux, меня больше всего поразил большой объем кода, который мне пришлось написать, чтобы изменить состояние. Изменение состояния требует объявления нового action, реализации соответствующего reducer и, наконец, отправки action. Redux также поощряет написание action creator, чтобы облегчить создание действия каждый раз, когда вы хотите его отправить.


Всеми этими действиями Redux усложняет понимание кода, его рефакторинг и отладку. При чтении кода, написанного кем-то другим, часто бывает трудно выяснить что выполняется при отправке action. Для начала нам придется погрузиться в код action creator, чтобы найти соответствующий тип действия, а затем найти reducers, которые обрабатывают этот тип действия. Все может стать еще сложнее, если используются некоторые middlewares, как, например, redux-saga, что делает контекст решения еще более неявным.


И, наконец, при использовании TypeScript, Redux может разочаровать. По замыслу action — это просто строки, связанные с дополнительными параметрами. Существуют способы написания хорошо типизированного кода Redux с помощью TypeScript, но это может быть очень утомительно и опять же может привести к увеличению объема кода, который нам придется написать.



Ощущения от изучения кода написанного с помощью Redux


Observable и hook: простой подход к управлению состоянием.


Заменяем Store c помощью Observable


Чтобы решить проблему совместного использования состояния более простым способом, нам прежде всего нужно найти способ уведомления компонентов при изменении общего состояния другими компонентами. Чтобы сделать это, давайте создадим класс Observable, который содержит единственное значение и который позволяет компонентам подписываться на изменения этого значения. Метод подписки будет возвращать функцию, которую необходимо вызвать, чтобы отписаться от Observable.


На TypeScript реализовать такой класс довольно просто:


type Listener<T> = (val: T) => void;
type Unsubscriber = () => void;

export class Observable<T> {
    private _listeners: Listener<T>[];

    constructor(private _val: T) {}

    get(): T {
        return this._val;
    }

    set(val: T) {
        if (this._val !== val) {
            this._val = val;
            this._listeners.forEach(l => l(val));
        }
    }

    subscribe(listener: Listener<T>): Unsubscriber {
        this._listeners.push(listener);
        return () => {
            this._listeners = this._listeners.filter(l => l !== listener);
        };
    }
}

Если сравнить этот класс c Redux Store, вы увидите, что они довольно похожи: get() соответствует getState(), а subscribe() — то же самое. Основное отличие заключается в методе dispatch(), который был заменен более простым методом set(), позволяющим изменять содержащееся в нем значение без необходимости полагаться на reducer. Другое существенное отличие состоит в том, что, в противоположность Redux, мы будем использовать множество Observable вместо одного Store, содержащего всё состояние.


Заменяем reducer сервисами


Теперь Observable можно применить для хранения общего состояния, но нам все еще нужно переместить логику, содержащуюся в reducer. Для этого мы используем концепцию сервисов. Сервисы — это классы, которые реализуют всю бизнес-логику наших приложений. Давайте попробуем переписать reducer Todo из туториала Redux в сервис Todo, используя Observable:


import { Observable } from "./observable";

export interface Todo {
    readonly text: string;
    readonly completed: boolean;
}

export enum VisibilityFilter {
    SHOW_ALL,
    SHOW_COMPLETED,
    SHOW_ACTIVE,
}

export class TodoService {
    readonly todos = new Observable<Todo[]>([]);
    readonly visibilityFilter = new Observable(VisibilityFilter.SHOW_ALL);

    addTodo(text: string) {
        this.todos.set([...this.todos.get(), { text, completed: false }]);
    }

    toggleTodo(index: number) {
        this.todos.set(this.todos.get().map(
            (todo, i) => (i === index ? { text: todo.text, completed: !todo.completed } : todo)
        ));
    }

    setVisibilityFilter(filter: VisibilityFilter) {
        this.visibilityFilter.set(filter);
    }
}

Сравнивая это с reducer Todo, мы можем отметить следующие различия:


  • Action были заменены методами, избавляя от необходимости объявлять action type, сам action и action creator.
  • Больше не нужно писать большой switch для маршрутизации между action type. Динамическая диспетчеризация Javascript (то есть вызовы методов) берет это на себя.
  • И что наиболее важно, сервис содержит и изменяет состояние, которым он управляет. Это большое концептуальное отличие от reducers, которые являются чистой функцией.

Доступ к сервисам и Observable из компонентов


Теперь, когда мы заменили «store и reducer из Redux» на «Observable и сервисы», нам нужно сделать сервисы доступными из всех компонентов React. Есть несколько способов сделать это: мы могли бы использовать IoC фреймворк, например, Inversify; использовать контекст React или применить тот же подход, что и в Store Redux, – один глобальный экземпляр для каждого сервиса. В этой статье мы рассмотрим последний подход:


import { TodoService } from "./todoService";

export const todoService = new TodoService();

Теперь мы можем получить доступ к общему состоянию и изменить его из всех наших компонентов React, импортировав экземпляр todoService. Но нам все еще нужно найти способ перерисовки наших компонентов, когда общее состояние изменяется другим компонентом. Чтобы это сделать, мы напишем простой hook, который добавляет переменную состояния к компоненту, подписывается на Observable и обновляет переменную состояния, когда значение Observable изменяется:


import { useEffect, useState } from "react";
import { Observable } from "./observable";

export function useObservable<T>(observable: Observable<T>): T {
    const [val, setVal] = useState(observable.get());

    useEffect(() => {
        setVal(observable.get()); // Добавление от @mayorovp
        return observable.subscribe(setVal);
    }, [observable]);

    return val;
}

Собираем все вместе


Наш инструментарий готов. Мы можем использовать Observable для хранения общего состояния в сервисах и использовать useObservable, чтобы гарантировать, что компоненты всегда будут синхронизированы с этим состоянием.


Давайте перепишем компонент TodoList из учебника Redux, используя новый hook:


import React from "react";
import { useObservable } from "./observableHook";
import { todoService } from "./services";
import { Todo, VisibilityFilter } from "./todoService";

export const TodoList = () => {
    const todos = useObservable(todoService.todos);
    const filter = useObservable(todoService.visibilityFilter);
    const visibleTodos = getVisibleTodos(todos, filter);

    return (
        <div>
            <ul>
                {visibleTodos.map((todo, index) => (
                    <TodoItem key={index} todo={todo} index={index} />
                ))}
            </ul>
            <p>
                Show: <FilterLink filter={VisibilityFilter.SHOW_ALL}>All</FilterLink>,
                <FilterLink filter={VisibilityFilter.SHOW_ACTIVE}>Active</FilterLink>,
                <FilterLink filter={VisibilityFilter.SHOW_ALL}>Completed</FilterLink>
            </p>
        </div>
    );
};

const TodoItem = ({ todo: { text, completed }, index }: { todo: Todo; index: number }) => {
    return (
        <li
            style={{
                textDecoration: completed ? "line-through" : "none",
            }}
            onClick={() => todoService.toggleTodo(index)}
        >
            {text}
        </li>
    );
};

const FilterLink = ({ filter, children }: { filter: VisibilityFilter; children: React.ReactNode }) => {
    const activeFilter = useObservable(todoService.visibilityFilter);
    const active = filter === activeFilter;
    return active ? (
        <span>{children}</span>
    ) : (
        <a href="" onClick={() => todoService.setVisibilityFilter(filter)}>
            {children}
        </a>
    );
};

function getVisibleTodos(todos: Todo[], filter: VisibilityFilter): Todo[] {
    switch (filter) {
        case VisibilityFilter.SHOW_ALL:
            return todos;
        case VisibilityFilter.SHOW_COMPLETED:
            return todos.filter(t => t.completed);
        case VisibilityFilter.SHOW_ACTIVE:
            return todos.filter(t => !t.completed);
    }
}

Как мы видим, мы написали несколько компонентов, которые обращаются к значениям общего состояния (todos и visibilityFilter). Эти значения изменяются просто путем вызова методов из todoService. Благодаря hook useObservable, который подписывается на изменения значений, эти компоненты автоматически перерисовываются при изменении общего состояния.


Вывод


Если мы сравним этот код с подходом Redux, то увидим несколько преимуществ:


  • Краткость: единственное, что нам нужно было сделать — это обернуть значения состояний в Observable и использовать hook useObservable при доступе к этим значениям из компонентов. Нет необходимости объявлять action, action creator, писать или комбинировать reducer или подключать наши компоненты к хранилищу с параметрами mapStateToProps и mapDispatchToProps.
  • Простота: теперь намного легче отслеживать выполнение кода. Понимание того, что на самом деле происходит при нажатии кнопки — это всего лишь вопрос перехода к реализации вызываемого метода. Пошаговое выполнение с помощью отладчика также значительно улучшено, поскольку между нашими компонентами и нашими службами нет промежуточного уровня.
  • Типобезопасность (type-safety) из коробки: от разработчиков TypeScript не потребуется дополнительной работы, чтобы иметь корректно типизированный код. Не нужно объявлять типы для состояния и для каждого action.
  • Поддержка async/await: хотя здесь это не было продемонстрировано, это решение прекрасно работает с асинхронными функциями, значительно упрощая асинхронное программирование. Не нужно полагаться на middleware, такое как redux-thunk, которое для понимания требует глубокие познания в области функционального программирования.

Redux, конечно, по-прежнему имеет некоторые серьезные преимущества, особенно Redux DevTools, позволяющие разработчикам наблюдать за изменениями состояния во время разработки и перемещаться во времени к прошлым состояниям приложения, что может быть отличным инструментом для отладки. Но по моему опыту, я редко использовал это, и цена, которую нужно заплатить, кажется слишком высокой для небольшой выгоды.


Во всех наших приложениях React и React Native мы с успехом использовали подход, аналогичный описанному в этой статье. На самом деле мы никогда не чувствовали необходимости в более сложной системе управления состоянием, чем эта.


Примечания


Класс Observable, представленный в этом посте, довольно прост. Его можно заменить более продвинутыми реализациями, такими как micro-observables (наша собственная библиотека) или RxJS.


Представленное здесь решение очень похоже на то, что может быть достигнуто с MobX. Основное отличие состоит в том, что MobX поддерживает глубокую мутабельность состояния объектов. Он также полагается на прокси-серверы ES6, чтобы уведомлять об изменениях, делая неявным повторный рендеринг, и усложняя отладку, когда все работает не так, как ожидалось. Вдобавок MobX не очень хорошо работает с асинхронными функциями.


От переводчика: Данная публикация, которую можно рассматривать как введение в управление состоянием с помощью Observable, является продолжением темы затронутой в статьей Управление состоянием приложения с RxJS/Immer как простая альтернатива Redux/MobX, где описывается как можно упростить использование данного подхода.

Похожие публикации

AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 27

    +4
    1) Берешь MobX
    2) Наслаждаешься
      0

      А если еще и state-tree прикрутить то вообще сказка
      https://github.com/mobxjs/mobx-state-tree


      В итоге код вообще становиться похожим на Vuex только в React
      Единственное что может Vuex но не может нормально mobx-state-tree это нормальный вызов одних action из других. Но эта фича скорее костыль...

        0
        А вот это уже не нужно, это как раз избыточная дополнительная кодовая нагрузка.
          0

          Некоторые так говорят вообще про все js фремворки разом =)


          Как по моему. Если это упрощает работу, улучшает стабильность и при этом кушает не сильно много — то почему бы и нет.

            0
            Упрощает работу? — Не согласен, только усложняет нагромождениями кода.
            Улучшает стабильность? — Если использовать TS, то это не актуально. Если использовать JS, то может быть незначительно, но это не стоит того, чтобы писать такой код.
        0
        На 100%
        0

        Хоть и перевод, но стоит отметить, что в хуке не происходит отписка от Observable. Да и в самом Observable такого функционала нет.

          +2

          Вроде все на месте.
          subscribe возвращает функцию


           return () => {
                      this._listeners = this._listeners.filter(l => l !== listener);
                  };

          которая возвращается из useEffect


          return observable.subscribe(setVal);
          +1

          Лол, только позавчера выбросил vuex в одном из проектов. Просто надоело то, как плохо оно все ложится на ts. Даже думал статеечку на хабр запилить, но там получилось все проще, чем можно ожидать. Упрощенно:
          class ServiceFactory
          {
          get MyService(): MyService
          {
          //в оригинале инстанс здесь кешируется
          retutn Vue.observable(new MyService());
          }
          }
          А инстанс ServiceFactory уже можно запихнуть как плагин vue, да и вообще куда угодно.
          О vuex пока добрым словом не вспоминал, подводных камней пока не нашел

            –2
            Типичная статья про «убийцу» Redux:

            Впервые прочитав официальный туториал по Redux, меня больше всего поразил большой объем кода, который мне пришлось написать, чтобы изменить состояние. Изменение состояния требует объявления нового action, реализации соответствующего reducer и, наконец, отправки action

            Дальше предлагается краткое описание замены и дальше идёт тот же самый «большой объём кода», только с видом сбоку.

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

            Щито? Добавить 3 строчки для production и development, чтобы подключить Redux extension — это слишком высокая цена?
              0
              Дальше предлагается краткое описание замены и дальше идёт тот же самый «большой объём кода», только с видом сбоку.

              Большой объем кода только из за демонстрации подхода, если обернуть все это в библиотеку получится вполне себе компактное решение.


              Щито? Добавить 3 строчки для production и development, чтобы подключить Redux extension — это слишком высокая цена?

              Тут речь не про цену подключения, а про использования Redux ради возможности использовать extension.

                0
                Где именно там компактное решение? Весь подход предлагаемого решения только в том, чтобы избавиться от 3 строчек кода с описанием action.

                    toggleTodo(index: number) {
                        this.todos.set(this.todos.get().map(
                            (todo, i) => (i === index ? { text: todo.text, completed: !todo.completed } : todo)
                        ));
                    }
                


                Господи, мои глаза :( И автор оригинальной статьи учит нас чему-то про Redux и удобно ли с ним работать. У меня больше нет вопросов и замечаний.
                  0
                  Где именно там компактное решение?

                  С возможным решением можно ознакомится в статье https://habr.com/ru/post/483526/
                  Будет выглядеть так:


                  toggleTodo(index: number) {
                    this.draft.todos[index].completed = !this.draft.todos[index].completed       
                  }
                    +1

                    Всегда пугали подобные конструкции, когда ради изменения одного флажка у ОБЪЕКТА мне зачем то нужно ре-сет сделать для все коллекции. Поэтому я пишу на Vue.

                0
                Я по возможности вместо такой записи:
                const todos = useObservable(todoService.todos);
                const filter = useObservable(todoService.visibilityFilter);
                const visibleTodos = getVisibleTodos(todos, filter);

                стремился бы к такой:
                const storeState = useObservable(todoService);
                const visibleTodos = todoService.getVisibleTodos(filter);

                  0

                  Этого достаточно просто достичь объявлением одного Observable


                  readonly state = new Observable({
                    todos: [] as Todo[], 
                    filter: VisibilityFilter.SHOW_ALL
                  });

                  const storeState = useObservable(todoService.state);
                  const visibleTodos = todoService.getVisibleTodos(storeState.filter);
                    0
                    Да, но тогда реализации из статьи теряется возможность подписываться на изменения только filter или только todos. Допиливать понадобиться.
                      0

                      Это пара строчек кода


                      function useStore<TState, TResult>(
                        store: SimpleImmutableStore<TState>,
                        project: (store: TState) => TResult,
                      ): TResult {
                      
                      export function useObservable<T, R>(observable: Observable<T>,  
                      +selector: (value: T) => R): R {
                      -    const [val, setVal] = useState(observable.get());
                      +    const [val, setVal] = useState(selector(observable.get()));
                      
                          useEffect(() => {
                      -        return observable.subscribe(setVal);
                      +        return observable.subscribe(value => setVal(selector(value)));
                          }, [observable]);
                      
                          return val;
                      }
                  +1

                  Вот так писать нельзя, этот код содержит ошибку:


                  export function useObservable<T>(observable: Observable<T>): T {
                      const [val, setVal] = useState(observable.get());
                  
                      useEffect(() => {
                          return observable.subscribe(setVal);
                      }, [observable]);
                  
                      return val;
                  }

                  Между рендером и его фиксацией значение observable может измениться, и вы этого никогда не узнаете.


                  Правильный вариант — вот такой:


                  export function useObservable<T>(observable: Observable<T>): T {
                      const [val, setVal] = useState(observable.get());
                  
                      useEffect(() => {
                          setVal(observable.get());
                          return observable.subscribe(setVal);
                      }, [observable]);
                  
                      return val;
                  }

                  А может быть, даже вот такой (зависит от того, что позволено делать в методе subscribe):


                  export function useObservable<T>(observable: Observable<T>): T {
                      const [val, setVal] = useState(observable.get());
                  
                      useEffect(() => {
                          const s = observable.subscribe(setVal);
                          setVal(observable.get());
                          return s;
                      }, [observable]);
                  
                      return val;
                  }

                  Но этот код на самом деле не является типобезопасным. Если вдруг ваш тип T является функцией, то подобная реализация будет работать совершенно не так как задумывалось! Правильнее будет написать как-то так:


                  export function useObservable<T>(observable: Observable<T>): T {
                      const [val, setVal] = useReducer((state, action) => action, observable.get());
                  
                      useEffect(() => {
                          const s = observable.subscribe(setVal);
                          setVal(observable.get());
                          return s;
                      }, [observable]);
                  
                      return val;
                  }

                  Но и этот код всё ещё не идеален, при смене самого observable будет лишний рендер. И как избавиться от него по-нормальному — я придумать не смог.

                    0

                    Можно посмотреть на другую реализацию https://github.com/LeetCode-OpenSource/rxjs-hooks/blob/master/src/use-observable.ts

                      0

                      Те же проблемы. inputFactory нельзя изменить, State не может быть функцией… Разве что проблемы с методом .get() нету, за отсутствием такого метода.

                        0

                        Что бы что то изменилось в observable перед отработкой useEffect должно произошло другое асинхронное событие. Вы можете продемонстрировать такое поведение?

                          +1
                          function Foo({ observable: Observable<string>() }) {
                              useEffect(() => observable.next("foo"));
                              return <div></div>
                          }
                            0

                            Хоть код и авторский, но добавил вашу правку, спасибо.

                    0
                    Vue and Vuex не нравится что во vue они расширяют глобальный скоуп. В больших проектах это аукнится. К тому же разговор про то что Vue использует Alibaba сомнительный, не видел и строчки кода на Vue там.

                    По поводу react reduxэто классика. сейчас effector топ…
                      0
                      Vue and Vuex не нравится что во vue они расширяют глобальный скоуп
                      Можно подробнее про это? А то я не в курсе. И там нет простого способа избежать этого?
                        0
                        Похоже вы просто не пробовали MobX

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

                      Самое читаемое