В предыдущем параграфе вы познакомились с основами Redux: почему он появился, какие проблемы решает и из каких сущностей состоит.
В этом мы рассмотрим продвинутые концепции, связанные с этим паттерном: как работать с данными, обновлять UI во Flutter и расщеплять редьюсеры.
Всё покажем на примерах — работать будем со счётчиком из предыдущего параграфа.
Приступим!
Работаем с данными
В предыдущем параграфе мы написали прекрасный счётчик. Он следует всем правилам хорошей архитектуры — чистая бизнес-логика, неизменяемое состояние, реактивная обработка.
Однако этот пример слишком далёк от реального мира, а реальный мир не совершенен! Походы в Сеть, загрузка из базы данных, любой асинхронный вызов и даже обычное логирование — всё это делает наши функции грязными, а значит, им не место в редьюсере.
Но ни одно серьёзное приложение не может обходиться без сохранения данных или отправки пары байтиков в Сеть. К счастью для нас, создатели Redux подумали об этом и представили концепцию под названием “middleware”.
Middleware — это функция, которая позволяет нам перехватывать и обрабатывать действия между тем моментом, когда они отправляются в Store, и тем, когда они достигают редьюсера. То есть она находится «между» отправкой действия и обработкой его редьюсером.
1 cat_files = []
2 for root, _, files in os.walk("фото"):
3 for file in files:
4 if "кот" in file.lower() or "кошка" in file.lower():
5cat_files.append(os.path.join(root, file))
В профессиональном сообществе такие функции называют «мидлвары», и мы тоже не будем отступать от такого наименования.
Мидлвары удобны для следующих задач:
-
Логирование.
-
Асинхронные операции.
-
Контроль и изменение потока.
Пример простейшей мидлвары для логирования:
1void loggerMiddleware(Store<AppState> store, action, NextDispatcher next) {
2 print('''{
3 datetime: ${DateTime.now()},
4 store: $store,
5 state: ${store.state},
6 action: $action,
7 }''');
8
9 next(action); // Отправляем действие дальше на исполнение
10}
Как видите, мы просто пишем в консоль информацию об актуальном состоянии и действии, которое совершается.
Обратите внимание, что мы должны передать наше действие дальше на исполнение с помощью функции next()
, без этого action
не дойдёт до редьюсера для обработки.
Далее нашу мидлвару необходимо добавить в Store:
1final store = Store<AppState>(
2 appReducer,
3 initialState: const AppState(),
4 middleware: [loggerMiddleware],
5);
После чего каждое новое событие будет отображаться в консоли.
Вы можете заметить, что аргумент middleware
представляет собой список. Это важное дополнение, о котором стоит отдельно упомянуть: мы можем передать столько разных мидлвар, сколько считаем нужным.
Важно!
Важно!
Мидлвары будут выполняться в том порядке, в котором они перечислены при создании Store.
Это важно понимать, потому что какая-то из мидлвар может обрывать исполнение какого-либо действия, а значит, идущие после неё остальные мидлвары не получат эти действия на исполнение.
Практический пример: счётчик с сохранением
Чтобы понять принципы применения мидлвар, доработаем наш счётчик: начнём сохранять его состояние в постоянную память, чтобы при открытии приложения мы получали предыдущее значение.
State
Так как теперь в счётчике подразумевается загрузка данных из памяти, нам необходимо обрабатывать ситуацию, когда данные грузятся. Для этого мы расширим наш data class состояния:
1enum CounterStatus {
2 loading,
3 idle,
4}
5
6final class CounterState {
7 final CounterStatus status;
8 final int value;
9
10 const CounterState({
11 required this.status,
12 required this.value,
13 });
14
15 CounterState copyWith({
16 CounterStatus? status,
17 int? value,
18 }) => CounterState(
19 status: status ?? this.status,
20 value: value ?? this.value,
21 );
22}
Как видите, мы добавили статус для нашего счётчика (CounterStatus
),который может принимать два состояния:
-
loading
— статус загрузки предыдущего сохранённого числа. -
idle
— статус, в котором мы готовы дальше работать со счётчиком: увеличивать его или уменьшать.
Здесь важно остановиться и подумать о бизнес-требованиях. К примеру, мы никак не обозначили состояние, в котором нам не удалось загрузить предыдущее состояние, — например, если данные повреждены.
Чтобы не усложнять пример, будем при любых проблемах загрузки сбрасывать счётчик до нуля. Однако имейте в виду, что в реальном мире ошибки нужно так или иначе обрабатывать.
Также вы могли заметить, что мы добавили в состояние метод copyWith
: это просто вспомогательный метод для упрощённого копирования состояния, он позволяет передавать не все аргументы при создании копии, а лишь те, которые мы действительно хотим изменить. Благодаря этому вместо:
1return CounterState(
2 status: state.status,
3 value: state.value + 1,
4);
мы можем написать:
1return state.copyWith(
2 value: state.value + 1,
3);
Сейчас, когда у нас всего два простых поля, это не кажется таким критичным. Но когда состояние состоит из десятка полей, эта простая техника позволит вам сэкономить кучу времени и избежать множества потенциальных багов.
Actions
Действия также претерпят изменения. Как мы уже говорили, при изначальной разработке счётчика наши действия могут быть сложными, например иметь аргументы.
1sealed class CounterAction {
2 const CounterAction();
3}
4
5final class Increment implements CounterAction {
6 const Increment();
7}
8
9final class Decrement implements CounterAction {
10 const Decrement();
11}
12
13final class AppStartup implements CounterAction {
14 const AppStartup();
15}
16
17final class LoadSuccess implements CounterAction {
18 final int value; // Предыдущее загруженное значение
19
20 const LoadSuccess(this.value);
21}
22
23final class LoadFailed implements CounterAction {
24 const LoadFailed();
25}
Здесь произошло сразу несколько важных изменений:
-
Вместо
enum
мы начали использовать полноценные классы. -
Чтобы сохранить ограниченность действий, мы используем sealed class.
-
Появились действия для загрузки данных.
-
Одно из действий (
LoadSuccess
) обзавелось аргументом.
Но идейно каждое действие всё ещё является какой-то простой и понятной операцией вроде «Увеличь счётчик на единицу» или «Данные успешно загрузились».
Reducer
Так как мы переехали с enum
на классы, нам уже заведомо придётся переписать наш редьюсер. Но также у нас появились новые события, которые стоит обработать.
Давайте предварительно обдумаем, какой результат от разных действий мы хотим получить?
-
Increment
иDecrement
: как и раньше, мы хотим просто изменить предыдущее значение на единицу, однако теперь нам дополнительно необходимо сохранить новое значение в постоянную память. -
AppStartup
: приложение только запустилось, значит, нам необходимо отобразить состояние загрузки и, собственно, начать эту самую загрузку. -
LoadSuccess
: загрузка завершилась успешно, и мы хотим использовать сохранённое значение. -
LoadFailed
: загрузка завершилась с ошибкой по любой причине, например файл повреждён или нет сохранённого состояния. В этом случае мы начинаем счётчик с нуля.
Имея подобного рода план, мы можем приступать к написанию кода:
1CounterState counterReducer(CounterState state, action) {
2 if (action is Increment) {
3 return state.copyWith(
4 value: state.value + 1,
5 );
6 } else if (action is Decrement) {
7 return state.copyWith(
8 value: state.value - 1,
9 );
10 } else if (action is AppStartup) {
11 return state.copyWith(
12 status: CounterStatus.loading,
13 value: 0,
14 );
15 } else if (action is LoadSuccess) {
16 return state.copyWith(
17 status: CounterStatus.idle,
18 value: action.value,
19 );
20 } else if (action is LoadFailed) {
21 return state.copyWith(
22 status: CounterStatus.idle,
23 value: 0,
24 );
25 }
26
27 return state;
28}
Вы можете обратить внимание, что у нас здесь нет самой загрузки данных из памяти.
И для этого мы как раз заведём мидлвару.
Middleware
Теперь нам необходимо определить мидлвару, которая будет слушать необходимые действия и на основе этого принимать какие-то решения.
1final class CounterMiddleware implements Middleware<CounterState> {
2 final CounterDatabase _database;
3
4 const CounterMiddleware(this._database);
5
6 call(Store<CounterState> store, action, NextDispatcher next) {
7 if (action is AppStarted) {
8 _database.getValue()
9 .then((value) => store.dispatch(LoadSuccess(value)))
10 .catchError((_) => store.dispatch(const LoadFailed()));
11 } else if (action is Increment) {
12 final newValue = store.state.value + 1;
13 _database.setValue(newValue);
14 } else if (action is Decrement) {
15 final newValue = store.state.value - 1;
16 _database.setValue(newValue);
17 }
18
19 next(action);
20 }
21}
Мы определили мидлвару как класс, чтобы иметь возможность получить нашу потенциальную базу данных через Dependency Injection.
Но это не единственный способ определить мидлвару: она может быть функцией, классом или асинхронной операцией. Чтобы проще писать мидлвары, существует множество пакетов на все случаи жизни, о них мы немного поговорим в конце параграфа.
Но обратите внимание, что наш редьюсер так и остался чистой функцией! Вся работа с грязным миром для него является не более чем деталью реализации, которая происходит где-то далеко в мидлваре.
Store
Теперь нам снова необходимо объединить всё, что мы сделали выше. Новая версия будет не сильно отличаться от старой:
1void main() {
2 // Создание Store
3 final store = Store<CounterStore>(
4 counterReducer,
5 initialState: const CounterState(value: 0, status: CounterStatus.loading),
6 middleware: [CounterMiddleware(counterDatabase)],
7 );
8
9 // Выводим любой результат изменения числа в консоль
10 // Обратите внимание, что мы выводим именно state.value, а не состояние целиком
11 store.onChange.listen((state) => print(state.value));
12
13 // Говорим нашему Store о том, что приложение запустилось
14 // и необходимо загрузить данные
15 store.dispatch(const AppStartup());
16
17 // Несколько раз увеличиваем число
18 store.dispatch(const Increment()); // Увеличиваем и сохраняем новое значение (1)
19 store.dispatch(const Increment()); // Увеличиваем и сохраняем новое значение (2)
20 store.dispatch(const Increment()); // Увеличиваем и сохраняем новое значение (3)
21
22 // Как итог в консоль выведется: 0, 1, 2, 3
23}
Теперь наш счётчик стал взрослее и реалистичнее!
Далее привяжем его к UI.
Обновляем UI во Flutter
Всё, что мы с вами сейчас изучили, касалось Redux в связке с Dart, но мы ни слова не сказали про Flutter.
И хотя независимость архитектурного решения от фреймворка само по себе отличное свойство, приложения мы обычно пишем на Flutter, и тут возникает резонный вопрос, как подружить это всё друг с другом?
Создатели пакета redux
под Dart также понимали, что это необходимо, и представили пакет flutter_redux
, который даёт всё необходимое, чтобы интегрировать Redux-решение в приложения, а именно:
-
StoreProvider
для включения стора в дерево виджетов. -
StoreConnector
для отрисовки UI на основе части состояния. -
StoreBuilder
для отрисовки UI с использованием всего стора.
Давайте рассмотрим их поближе.
StoreProvider
Чтобы иметь возможность завязаться на наш Store и его состояние в виджетах, нам необходимо где-то положить этот самый Store.
Для этого существует виджет StoreProvider
, которые принимает на вход сам store
и Widget child
. StoreProvider
— это самый обычный InheritedWidget
, который держит ссылку на стор и отдаёт его всем нижележащим виджетам.
Однако в его работе есть несколько особенностей относительно других популярных решений, вроде flutter_bloc
:
-
StoreProvider
всегда ждёт в аргументах экземпляр вашего конкретного Store без возможности создать и утилизировать его «на месте», как это можно сделать сBlocProvider
. -
Так как Redux подразумевает, что у вас будет одно состояние на всё приложение, то
StoreProvider
стоит располагать максимально близко к корню дерева виджетов.
1class App extends StatelessWidget {
2 final Store<AppState> _store;
3
4 const App({
5 required Store<AppState> store,
6 super.key,
7 }) : _store = store;
8
9 @override
10 Widget build(BuildContext context) {
11 return StoreProvider<AppState>( // StoreProvider в самом корне приложения
12 store: _store,
13 child: MaterialApp(
14 home: HomePage(),
15 ),
16 );
17 }
18}
StoreConnector
Дальше нам необходимо как-то завязываться на наш Store
и его состояние, чтобы отображать UI. Для этого существует виджет StoreConnector
. Если вы уже знакомы с BLoC, то ближайший аналог — это BlocSelector
.
Идейно это просто виджет, который позволяет вам смаппить полное состояние приложения из стора в какую-то локальную ViewModel
.
Примечание
Примечание
Название ViewModel
здесь используется не в том смысле, к которому привыкли мобильные разработчики!
Это буквально «модель данных для отображения», а не часть паттерна MVVM.
К примеру, мы можем взять наше общее состояние и узнать, находится ли наше приложение в состоянии загрузки:
1return StoreConnector<AppState, bool>(
2 converter: (store) {
3 // Возвращаем какое-то конкретное значение на основе общего состояния
4 //
5 // В данном случае мы возвращаем флаг о том, идёт ли загрузка
6 return store.status == Status.loading;
7 }
8 builder: (context, isLoading) {
9 // На основе полученного выше значения строим виджет
10
11 // Если загрузка идёт, то показываем лоадер
12 if (isLoading) {
13 return const CircularProgressIndicator();
14 }
15
16 // Если же загрузка завершилась, то показываем контент
17 return const ContentPage();
18 }
19);
Также StoreConnector
принимает на вход аргумент distinct
, который позволяет не перестраивать виджет в ситуациях, когда ViewModel
не изменилась. Если ваши модели корректно переопределяют метод сравнения (==
) и hashCode
, то рекомендуется выставлять distinct: true
.
StoreBuilder
Хотя всегда предпочтительно использовать StoreConnector
, потому что он оптимальнее работает с конкретными данными, всё же иногда бывает необходимость завязаться на весь стор. Для этого существует виджет StoreBuilder
.
1return StoreBuilder<AppState>(
2 builder: (context, store) {
3 // Аналогичная примеру выше логика, но с использованием всего стора вместо получения конкретного значения.
4
5 if (store.status == Status.loading) {
6 return const CircularProgressIndicator();
7 }
8
9 return const ContentPage();
10 }
11);
Примечание
Примечание
Используйте StoreBuilder
, только если понимаете, зачем вам это.
Во всех остальных случаях предпочтительно использовать StoreConnector
.
Практический пример: UI для счётчика
Давайте теперь сделаем UI для нашего счётчика.
Для начала создадим виджет самого приложения:
1class CounterApp extends StatelessWidget {
2 final Store<CounterState> _store;
3
4 const CounterApp({
5 required Store<CounterState> store,
6 super.key,
7 }) : _store = store;
8
9 @override
10 Widget build(BuildContext context) {
11 // Пробрасываем наш Store в дерево
12 return StoreProvider<CounterState>(
13 store: _store,
14 child: MaterialApp(
15 home: const CounterPage(),
16 ),
17 );
18 }
19}
Теперь определим CounterPage
, который будет представлять собой входную точку в счётчик.
На этой странице будет решаться, что показать — индикатор загрузки или сам счётчик, в зависимости от состояния.
1class CounterPage extends StatelessWidget {
2 const CounterPage({
3 super.key,
4 });
5
6 @override
7 Widget build(BuildContext context) {
8 return StoreSelector<CounterState, bool>(
9 distinct: true,
10 selector: (store) {
11 // Получаем информацию о том, происходит ли сейчас загрузка счётчика
12 return store.state.status == CounterStatus.loading;
13 },
14 builder: (context, isLoading) {
15 if (isLoading) {
16 // Если загрузка происходит, просто показываем лоадер
17 return const Center(
18 child: CircularProgressIndicator(),
19 );
20 }
21
22 // Иначе выводим контент, который состоит из двух кнопок и актуального числа
23 return const Column(
24 children: [
25 DecrementButton(),
26 CounterWidget(),
27 IncrementButton(),
28 ],
29 );
30 }
31 );
32 }
33}
Как видите, внутри этой страницы мы определили виджеты для отдельных кусочков экрана: DecrementButton
, CounterWidget
и IncrementButton
. Кнопки будут выступать для нас инициаторами Actions
, а виджет счётчика будет выводить актуальное значение на экран.
Давайте напишем их!
Код
1class CounterWidget extends StatelessWidget {
2 const CounterWidget({
3 super.key,
4 });
5
6 @override
7 Widget build(BuildContext context) {
8 return StoreSelector<CounterState, int>(
9 distinct: true, // При помощи этого виджет будет перестраиваться, только когда обновится число
10 selector: (store) {
11 // Получаем актуальное значение счётчика
12 return store.state.value;
13 },
14 builder: (context, value) {
15 final theme = Theme.of(context);
16
17 // Выводим значение счётчика на экран
18 return DefaultTextStyle.merge(
19 style: theme.textTheme.headline,
20 child: Text('$value'),
21 );
22 }
23 );
24 }
25}
26
27class IncrementButton extends StatelessWidget {
28 const IncrementButton({
29 super.key,
30 });
31
32 @override
33 Widget build(BuildContext context) {
34 // Здесь мы можем видеть один из способов получения Store
35 // Использование `StoreBuilder` с аргументом `rebuildOnChange: false`
36 // Таким образом наш виджет не будет перестраиваться при изменении состояния, но у нас будет ссылка на Store
37 return StoreBuilder<CounterState>(
38 rebuildOnChange: false,
39 builder: (context, store) {
40 return IconButton(
41 onTap: () {
42 // Отправляем действие на обработку в стор
43 store.dispatch(const Increment());
44 },
45 child: Icon(Icons.add),
46 );
47 }
48 );
49 }
50}
51
52class DecrementButton extends StatelessWidget {
53 const DecrementButton({
54 super.key,
55 });
56
57 @override
58 Widget build(BuildContext context) {
59 return IconButton(
60 onTap: () {
61 // Здесь мы можем видеть другой, более каноничный способ получения Store
62 final store = StoreProvider.of<CounterState>();
63 // Отправляем действие на обработку в стор
64 store.dispatch(const Decrement());
65 },
66 child: Icon(Icons.remove),
67 );
68 }
69}
И как итог давайте научимся это всё запускать. Для этого нам потребуется функция main
:
1void main() {
2 // Создаём наш глобальный стор
3 final store = Store<CounterStore>(
4 counterReducer,
5 initialState: const CounterState(value: 0, status: CounterStatus.loading),
6 middleware: [CounterMiddleware(counterDatabase)],
7 );
8
9 store.dispatch(AppStartup());
10
11 // И передаём его в наше приложение для дальнейшей работы
12 runApp(CounterApp(store: store));
13}
Наше приложение-счётчик готово!
Теперь мы можем изменять его значения через UI, а в фоне ещё и сохранять их в память.
Композиция бизнес-логики
Иногда приложения — даже если это приложение-счётчик — растут.
Функций становится всё больше, количество действий неумолимо увеличивается, редьюсер распухает, а состояние перестает умещаться на 4К-монитор.
Монолитный редьюсер приводит к ряду проблем:
-
Гигантская конструкция из кучи
if
, в которой легко допустить ошибку. -
Каждое изменение функции влияет на всё приложение, тем самым повышая риски.
-
Неизбежные конфликты при слиянии при работе в команде.
-
Тестирование и даже простое чтение файла становится непомерно сложным.
Однако, несмотря на эти сложности, Redux всё ещё требует, чтобы мы работали с одним глобальным состоянием на всё приложение.
Решение простое: мы можем разделить наш редьюсер на несколько редьюсеров поменьше, каждый из которых будет отвечать за свой независимый кусочек итогового состояния. А в основном редьюсере — объединить их результаты.
Сейчас покажем на практике!
Практический пример: добавляем тему приложения
Давайте добавим поддержку выбора светлой или тёмной темы, потому что ни одно серьёзное приложение в 2025 году не может обойтись без этой настройки. Если это, конечно, не Spotify!
State
Для начала надо определить новое состояние, которое будет отвечать за тему нашего приложения. Для простоты мы завяжемся прямиком на класс ThemeMode
из Flutter.
Но добавлять новое поле в наш CounterState
неправильно. Ведь тема приложения никак не соотносится с состоянием счётчика и является полностью обособленной частью. По этой причине мы выведем отдельное глобальное состояние:
1final class AppState {
2 final CounterState counter;
3 final ThemeMode themeMode;
4
5 const AppState({
6 required this.counter,
7 required this.themeMode,
8 });
9}
Как видите, наш новоиспечённый AppState
— это сборная солянка всех остальных состояний приложения. Так и должно быть! Когда позже вы решите добавить историю счётчика, настройки выбора языка, авторизацию и рекламу — все новые состояния будут частью общего AppState
, ведь Redux требует, чтобы состояние было единым!
Actions
Теперь что касается действий.
У нашего CounterState
был свой базовый CounterAction
вокруг которого строились все действия, связанные со счётчиком. Теперь нам нужно что-то аналогичное для нашей темы.
1sealed class ThemeModeAction {
2 const ThemeModeAction._();
3}
4
5final class SetThemeMode implements ThemeModeAction {
6 final ThemeMode mode;
7
8 const SetThemeMode(this.mode);
9}
И хотя у нас всего одно действие, всё равно добавим базовый класс ThemeModeAction
. Причина проста — мы заранее закладываем родительский класс ThemeModeAction
на потенциальное расширение функциональности.
Reducer
Теперь самое интересное. Как же комбинировать редьюсеры?
Помним, что у нас уже есть написанный counterReducer
, который делает всё необходимое для работы счётчика. Давайте для начала напишем такой же для нашей темы:
1ThemeMode themeModeReducer(ThemeMode currentMode, action) {
2 if (action is SetThemeMode) {
3 // Возвращаем новое состояние темы, если это нужное нам действие
4 return action.mode;
5 }
6
7 // Не забываем вернуть оригинальное состояние, если ничего не сделали
8 return currentMode;
9}
Невероятно простая функция. Возвращаем новый режим, когда он пришёл, иначе возвращаем предыдущий.
Теперь давайте напишем новый редьюсер для глобального состояния, который будет объединять остальную бизнес-логику:
1AppState appReducer(AppState state, action) {
2 return AppState(
3 counter: counterReducer(state.counter, action),
4 themeMode: themeModeReducer(state.themeMode, action),
5 );
6}
Вот и всё! Наш глобальный редюсер просто берёт и собирает новое состояние на основе всех остальных редьюсеров.
Отдельная прелесть этого подхода состоит в том, что вам не обязательно останавливаться на высокоуровневых функциях, вы можете делить их дальше — настолько, насколько считаете нужным и удобным.
Добавляем строгую типизацию
Сейчас все наши редьюсеры получают action
как dynamic
, а в предыдущем параграфе мы обещали, что сможем использовать строгую типизацию в редьюсерах. Настало её время!
Reducer
Для этого существует класс TypedReducer
. Это простая обёртка вокруг вашей функции, которая позволяет обрабатывать только конкретный тип действий. Выглядит это так:
1ThemeMode themeModeReducer(ThemeMode currentMode, ThemeModeAction action) {
2 return switch(action) {
3 SetThemeMode() => action.mode,
4 };
5}
Обратите внимание, что:
-
Аргумент
action
больше неdynamic
, а вполне себе конкретного типа (ThemeModeAction
). -
Нам больше не надо обрабатывать «неизвестные» ситуации.
Теперь при помощи TypedReducer
давайте поправим наш глобальный редьюсер:
1AppState appReducer(AppState state, action) {
2 return AppState(
3 counter: counterReducer(state.counter, action),
4 themeMode: TypedReducer<ThemeMode, ThemeModeAction>(themeModeReducer),
5 );
6}
Теперь наш themeModeReducer
будет вызываться только в случае, когда action
действительно является наследником ThemeModeAction
.
Примечание
Примечание
Redux также предоставляет нам класс TypedMiddleware
.
Он сделан с той же целью — возможность добавить мидлвару, которая обрабатывает конкретные действия, а не все.
Store
И снова давайте соберем всё воедино.
1final store = Store<AppState>(
2 initialState: const AppState(
3 counter: CounterState(value: 0, status: CounterStatus.loading),
4 themeMode: ThemeMode.dark,
5 ),
6 reducer: appReducer,
7 middleware: [CounterMiddleware(counterDatabase)],
8);
Отлично, вы многому научились! Теперь самое время подвести итоги.
Выводы
Как и любой инструмент, Redux обладает плюсами и минусами. И хотя это невероятно мощный и гибкий подход, он всё ещё может быть избыточен для ряда приложений.
Но обо всём по порядку!
Плюсы
- Предсказуемое управление состоянием. Redux следует строгим принципам, которые делают поведение приложения предсказуемым. Состояние изменяется только через редьюсер и только на предопределённые действия, что исключает случайные мутации данных. Вы всегда знаете, где и как изменилось состояние, что значительно упрощает отладку и понимание логики приложения.
- Прозрачность изменений. Каждое изменение состояния — это явно определённое действие, которое чётко описывает, что именно происходит в приложении. Это создаёт своеобразный журнал всех изменений, который можно легко отследить. В отличие от подходов, где состояние может изменяться в любом месте кода, Redux даёт нам полную картину того, какие события привели к текущему состоянию. Это особенно полезно при поиске багов и анализе пользовательского поведения.
- Масштабируемость. Архитектура остается стабильной даже при росте приложения. Чёткое разделение на действия, редьюсер и состояние позволяет легко добавлять новую функциональность, не затрагивая существующую. Когда ваше приложение вырастет с 5 экранов до 50, Redux не рассыплется, продолжит работать по тем же принципам. Это делает его отличным выбором для долгосрочных проектов, где важна стабильность архитектуры.
- Тестируемость. Redux-приложения легко покрывать тестами благодаря чистоте функций и предсказуемости. Редьюсер — это чистая функция, которая тестируется элементарно: подали состояние и действие на вход, получили ожидаемое новое состояние. Actions тоже легко тестировать, а мидлвары позволяют изолированно тестировать побочные эффекты. В результате вы можете достичь высокого покрытия тестами бизнес-логики, что критично для надёжных приложений.
Минусы
-
Давно не обновлялся. На момент написания этого параграфа последнее обновление пакета
redux
было 4 года назад, что по меркам ИТ-индустрии почти вечность. И хотя принципы и основы, которые даёт нам Redux, не изменились, это всё равно может быть серьёзной причиной для отказа от технологии. -
Много бойлерплейта. Каждая архитектура в той или иной мере заставляет нас писать избыточный код, и Redux — не исключение. А разделение логики на «чистую» и «грязную» дополнительно стимулирует нас выделять сущности и писать большое количество сервисного кода.
-
Крутая кривая обучения. Из-за особенностей Redux новичкам сложнее вникнуть и начать правильно использовать подход, не вставляя себе палки в колеса. Здесь нужна практика и желание разобраться. Важно понимать, что принципы, которым следует Redux, используются не только в нём но и во многих других архитектурных подходах, например MVI, Elm, Flux.
-
Избыточность для малых проектов и MVP. Redux подразумевает, что у вас есть время на то, чтобы серьёзно планировать разработку приложения, анализировать проблемы, а в идеале покрывать (хотя бы) бизнес-логику тестами. Если времени нет — обратите внимание на архитектуру BLoC или Riverpod, о которых мы рассказывали ранее.
-
Производительность. К сожалению, главная особенность Redux приводит к проблемам с производительностью. Речь, конечно же, про единое состояние. Когда всё состояние вашего большого приложения выражается через один большой класс, намного проще допустить ошибку из-за которой мы можем перегрузить сборщик мусора или заставлять перестраиваться виджеты, когда этого делать не нужно. Здесь может помочь лишь практика и внимательность. Но запомните — не оптимизируйте приложение, если оно не лагает.
Когда использовать
-
Приложения с большим и сложным состоянием. Когда у вас много взаимосвязанных данных, которые используются на разных экранах приложения. Например, пользовательские настройки, корзина покупок, данные профиля, кеш запросов — всё это должно быть доступно из любой точки приложения. Redux отлично справляется с организацией такого состояния и предотвращает хаос и дублирование данных. Выше мы говорили, что у Redux имеются проблемы с производительностью именно из-за сложного состояния, но важно понимать — проблемы не с самим подходом, а с тем, как его использовать.
-
Масштабные проекты. Проекты, где работает большая команда разработчиков или приложение планируется развивать годами. Redux вводит строгие правила, которые помогают поддерживать порядок в коде даже при росте команды. Когда у вас много разработчиков и сотни экранов, предсказуемость архитектуры становится критически важной.
-
Желание делать чистую бизнес-логику. Redux заставляет держать всю логику в чистых функциях, что делает код более надёжным и предсказуемым. Это особенно важно для финтеха, медицины и других критичных областей, где ошибки дорого стоят.
Помните, что стейт-менеджер — это инструмент, а не самоцель. Важно с его помощью эффективно решать задачи, а не пытаться доказать кому-то, что один подход лучше другого. Возьмите из Redux его лучшие идеи и делайте с их помощью архитектуру своего приложения лучше или не используйте их вовсе, если они вам мешают.
На этом мы почти закончили — дальше вас ждёт добровольное домашнее задание и пара бонусов.
Домашнее задание
Если хотите лучше разобраться с Redux, то вот несколько упражнений на дом:
-
Доработайте
counterReducer
на использованиеTypedReducer
и конкретного типа. -
Сохраняйте режим темы в постоянную память и загружайте её при помощи нового Middleware.
-
Добавьте кнопку переключения темы в UI.
Бонусная часть
Библиотеки для работы с мидлварами
Писать мидлвары в виде функций с конкретной семантикой не всегда бывает удобно. К счастью, механизм работы с ними достаточно гибок, что позволило появиться большому количеству вспомогательных пакетов:
-
redux_thunk — для удобной обработки асинхронных операций.
-
redux_future — работа с Future как Middleware.
-
redux_epics — работа со стримами как Middleware.
Используйте их, чтобы сделать свой код проще.
Инструменты разработчика
За счёт простоты устройства Redux разработчики смогли реализовать такую прекрасную вещь, как Time Travel.
Идея заключается в том, что Store может сохранять историю всех действий и состояний, которые происходили и возникали, после чего давать возможность вернуться назад. Это буквально маховик времени для нашей архитектуры. Вокруг этого пакета выстроена большая инфраструктура — готовый UI, внешние инструменты, возможность написать свою логику.
Подробнее об этом можно прочитать тут.
А в следующем параграфе мы поговорим об архитектурных фреймворках GetX и Riverpod. Узнаем, какие преимущества они дают, но и не забудем о подводных камнях.