Вступление лирическое
Представьте себе ситуацию: вы освоили всё необходимое для работы, успешно работаете, проходит год, другой, и вдруг осознаёте, что, живя в своём уютном информационном пузыре, до сих пор не знаете, когда проходит WWDC или на какой версии Swift вы пишете
Да, я оказалась именно в таком пузыре. Всё вроде бы шло хорошо: задачи решались, меня хвалили, и не было никакой необходимости узнавать что-то новое. Конечно, я всё же чему-то училась, но точечно, по мере необходимости
И вот, пару раз споткнувшись в разговорах об свою ограниченность, прокринежевав потом от стыда, решила, что нужно что-то делать. Подписалась на кучу IOS каналов в телеграме, они стали моими мотиваторами к развитию не помню, как к этому пришла, но советую всем
До этого все мои приложения были написаны на UIKit, поняла что нужно переходить на SwiftUI. Придумала приложение, которое буду писать, решила использовать SwiftData для хранения данных. Почему бы и нет? Если уж начинать новый проект, так пусть он будет во всем новом
Долго выбирала архитектуру и остановилась на Redux, главная сложность была в разношерстной информации. Не хватало общей картины, и, хотя я находила полезные фрагменты, их было сложно сложить воедино, особенно подружить Redux со SwiftData. А как их объединять, если тычешь пальцем в небо? К тому же, хотелось заложить такую основу, чтобы приложение легко делилось на модули в будущем
В целом, я потратила не мало времени, чтобы разобрать и сформировать найденную информацию. И, естественно, это не эталон, я даже не претендую. Но, считаю, это хорошая теория для старта или тренажер для перестроения мышления на однонаправленную архитектуру
Вступление для тех кто торопится
На примере создания приложения pазберем архитектуру Redux, учтем возможное разделение приложения на модули. Будем постепенно наращивать уровень сложности (ориентир сложности: 🧠) В конце добавим работу со SwiftData, что закрепит понимание передачи данных не только в хранилище, но и по структурам Redux
Папки
Странно? Зачем начинать с расположения папок, и зачем вообще об этом говорить?
Я считаю, легче изучать тему от общего к частному, в нашем случае от структуры проекта к его реализации. Такое формирование структуры файлов не относится к Flux архитектуре. Здесь наша задача заложить фундамент для разделения приложения на модули в будущем. У меня получилось 4 абстрактных блока (папки)
App - общие файлы проекта, здесь располагается main (точка входа в приложение), а также элементы касающиеся приложения в целом. Эти элементы никогда не выносят в отдельные модули
Shared - это сервисы, утилиты, расширения, которые используются в разных частях приложения. В отличии от элементов папки App содержимое Shared можно вынести в отдельные модули или проекты, они никак не связаны с философией приложения, грубо говоря "чистое железо"
Features - хранит папки с фичами. Каждая фича в идеале должна быть изолирована от других фичей. Это позволит с легкостью выносить фичи в модули по надобности
Resources - содержит файлы с ресурсами для приложения в целом. Например, картинки, строки, информация с ключевыми настройками (.plist)
Основные термины Redux
В Redux есть термины, которые я до знакомства с архитектурой не встречала в программировании, но, возможно, я одна такая из пещеры вылезла...
State(системное) - текущее состояние, этим типом может быть Struct, Enum, Class или Primitive Type(
Int
,String
, илиBool)
Action(системное)- описание того, что должно произойти, этим типом может быть Enum, Struct или Protocol
Reducer - функция, которая определяет, как изменяется состояние (State) в ответ на определённое действие (Action)
Store - объединяет в себе состояние (State) и связанный с ним Reducer
Разберем на самом простом примере. Создадим все эти элементы для экрана со списком задач, назовем фичу TaskList
Но сначала определим структуру расположения новых файлов
В папке фичи, создаем 3 новые директории:
Domain - содержит интерфейсное описание бизнес задачи, абстракция с моделями
Application - это также бизнес задачи, но уже их реализация, логика и представления, но без деталей
Infrastructure - низкоуровневые сервисы и утилиты, подобные элементам Shared, но относящиеся только к данной фиче
Создадим TaskModel, тут все как обычно
struct TaskModel: Equatable, Identifiable {
var id = UUID()
var title: String
var subtitle: String?
var isCompleted = false
}
Теперь создадим State, который будет содержать в себе массив TaskModel и свойство TaskListFilter, которое будет определять, как сортировать или фильтровать список задач. Обратите внимание, что структура TaskListState подписана только на Equatable, но выступает в роли State
enum TaskListFilter {
case all
case active
case completed
}
struct TaskListState: Equatable {
var tasks: [TaskModel] = []
var filter: TaskListFilter = TaskListFilter.active
var filteredTasks: [TaskModel] {
switch filter {
case .all:
return tasks
case .active:
return tasks.filter { !$0.isCompleted }
case .completed:
return tasks.filter { $0.isCompleted }
}
}
}
Создадим Action, это будут все возможные действия над нашими задачами. Обратите внимание, что enum TaskListAction не подписан на какие либо протоколы, но выступает в роли Action
enum TaskListAction {
case addTask(TaskModel)
case toggleTaskCompletion(UUID)
case removeTask(UUID)
case filterTasks(TaskListFilter)
}
Создадим Reducer. Это функция, да! Без структур и классов, просто файл, в котором лежит функция. Она решает, как будет меняться State в зависимости от пришедшего в нее Action. Обратите внимание, что State приходит в эту функцию в качестве inout параметра
func taskListReducer(_ state: inout TaskListState, action: TaskListAction) {
switch action {
case .addTask(let task):
state.tasks.append(task)
case .toggleTaskCompletion(let id):
if let index = state.tasks.firstIndex(where: { $0.id == id }) {
state.tasks[index].isCompleted.toggle()
}
case .removeTask(let id):
state.tasks.removeAll { $0.id == id }
case .filterTasks(let filter):
state.filter = filter
}
}
И последняя, соединяющая State и Action структура, а точнее класс. Здесь не требуется конкретная реализация под фичу TaskList, это будет дженерик класс. Напишем простейший из вариантов реализации:
Уровень сложности Store 🧠
class Store<State, Action>: ObservableObject where State: Equatable {
@Published private(set) var state: State
private private let reducer: (inout State, Action) -> Void
init(initial state: State, reducer: @escaping (inout State, Action) -> Void) {
self.reducer = reducer
self.state = state
}
func dispatch(_ action: Action) {
self.reducer(&self.state, action)
}
}
Сначала сложно понять что куда и зачем. Во всем этом я вижу две задачи: предоставление информации и обработка этой информации действием
Информация по состоянию нам всегда открыта, обращаемся к ней без дополнительных приседаний
А вот изменять состояние мы должны только через метод dispatch в Store. Это позволяет реализовать дополнительный функционал обработки. Например, запросы в сеть, сохранение или логирование. Так же здесь ведется вся логика обработки действий
Добавим представление с возможностью удаления таски для мини демонстрации и вызовем его в main
struct TaskListView: View {
@State var store: Store<TaskListState, TaskListAction>
var body: some View {
NavigationView {
List {
ForEach(store.state.filteredTasks) { task in
Text(task.title)
}
.onDelete { indexSet in
indexSet.map { store.state.tasks[$0].id
}
.forEach { id in
store.dispatch(.removeTask(id))
}
}
}
}
}
}
#Preview {
TaskListView(
store:
Store(
initial: TaskListState(
tasks: [TaskModel(title: "Task 1"),
TaskModel(title: "Task 2")]
),
reducer: taskListReducer
)
)
}
@main
struct TasksAlarmApp: App {
var body: some Scene {
WindowGroup {
TaskListView(
store: Store(
initial: TaskListState(tasks: [
TaskModel(title: "Task 1"),
TaskModel(title: "Task 2")
]),
reducer: taskListReducer
)
)
}
}
}
Уровень сложности Store 🧠 🧠
Будем постепенно добавлять функционал в Store, идем от общего к частному, помните? Чтобы дойти до уровня, где мы будем уже внедрять SwiftData
Создаем рядом со Store. Observer.swift в Shared -> Flux
Observer - наблюдатель, с единственной открытой функцией исполнения. После исполнения наблюдатель может отписаться от наблюдения вернув ObserverStatus.dead
enum ObserverStatus {
case alive
case dead
}
final class Observer<State> {
private let observeBlock: (State) -> ObserverStatus
init(observe: @escaping (State) -> ObserverStatus) {
self.observeBlock = observe
}
func observe(_ state: State) -> ObserverStatus {
return observeBlock(state)
}
}
// Позволяет использовать Observer в Set, что необходимо
// для хранения наблюдателей в Store
extension Observer: Hashable {
static func == (lhs: Observer<State>, rhs: Observer<State>) -> Bool {
return lhs === rhs
}
func hash(into hasher: inout Hasher) {
hasher.combine(ObjectIdentifier(self))
}
}
Теперь усложняем Store:
class Store<State, Action>: ObservableObject where State: Equatable {
@Published private(set) var state: State
private let reducer: (inout State, Action) -> Void
private var observers: Set<Observer<State>> = []
init(initial state: State, reducer: @escaping (inout State, Action) -> Void) {
self.reducer = reducer
self.state = state
}
func dispatch(_ action: Action) {
self.reducer(&self.state, action)
self.notifyObservers()
}
func subscribe(observer: Observer<State>) {
self.observers.insert(observer)
self.notify(observer)
}
private func notifyObservers() {
for observer in observers {
notify(observer)
}
}
private func notify(_ observer: Observer<State>) {
if observer.observe(state) == .dead {
observers.remove(observer)
}
}
}
Таким образом, мы расширили функционал хранилища. Что нового?
subscribe - ОТКРЫТАЯ функция для добавления наблюдателя (Observer)
Set<Observer<State>> - хранилище для наблюдателей
notifyObservers - функция оповещения наблюдателей, срабатывает после изменения State
notify - функция оповещения одного наблюдателя, срабатывает сразу после установки наблюдателя
Добавим наблюдателя в наш проект. Он будет срабатывать при каждой отрисовке View
struct TaskListView: View {
@State var store: Store<TaskListState, TaskListAction>
var body: some View {
NavigationView {
List {
ForEach(store.state.filteredTasks) { task in
Text(task.title)
}
.onDelete { indexSet in
indexSet.map { store.state.tasks[$0].id }
.forEach { id in
store.dispatch(.removeTask(id))
}
}
}
.onAppear {
store.subscribe(observer: Observer { newState in
print("Состояние изменилось: \(newState.tasks)")
return .alive
})
}
}
}
}
При удалении каждой из задач меняется State, и Store уведомляет всех своих наблюдателей об этом
Что обычно может происходить в наблюдателях (Observer):
Обновление пользовательского интерфейса
Сохранение данных
Уведомление пользователя
Синхронизация с сервером
Обновление статистики или аналитики
Уровень сложности Store 🧠 🧠 🧠
Создаем Middleware.swift в Shared -> Flux
Middleware - это своего рода «посредник», который может делать что-то полезное с действиями до того, как оно достигнет редьюсера (reducer). Сюда могут входить:
Асинхронные запросы к API
Логирование действий
Обработка аутентификации
Изменение структуры данных
Реализация:
protocol Middleware {
associatedtype State
associatedtype Action
func process(action: Action,
state: State,
next: @escaping (Action) -> Void)
}
struct AnyMiddleware<State, Action>: Middleware {
private let _process: (Action, State, @escaping (Action) -> Void) -> Void
init<M: Middleware>(_ middleware: M) where M.State == State, M.Action == Action {
self._process = middleware.process
}
func process(action: Action,
state: State,
next: @escaping (Action) -> Void) {
_process(action, state, next)
}
}
class Store<State, Action>: ObservableObject where State: Equatable {
@Published private(set) var state: State
private let reducer: (inout State, Action) -> Void
private var observers: Set<Observer<State>> = []
private var middleware: [AnyMiddleware<State, Action>]
init(
initial state: State,
reducer: @escaping (inout State, Action) -> Void,
middleware: [AnyMiddleware<State, Action>] = []
) {
self.reducer = reducer
self.state = state
self.middleware = middleware
}
func dispatch(_ action: Action) {
let middlewareChain = middleware.reversed().reduce({ action in
self.reducer(&self.state, action)
self.notifyObservers()
}) { next, mw in
return { action in
mw.process(action: action, state: self.state, next: next)
}
}
middlewareChain(action)
}
func subscribe(observer: Observer<State>) {
self.observers.insert(observer)
self.notify(observer)
}
private func notifyObservers() {
for observer in observers {
notify(observer)
}
}
private func notify(_ observer: Observer<State>) {
if observer.observe(state) == .dead {
observers.remove(observer)
}
}
}
И вот тут снова у меня возникли сложности с пониманием, давайте разберем, что происходит в функции dispatch. Мы формируем цепочку из посредников (middleware) с конца в начало, передавая им еще не измененный State и ссылки на следующий (next) middleware. Описание комментариями в коде:
func dispatch(_ action: Action) {
// Идем через middleware в обратном порядке, чтобы построить цепочку обработки
let middlewareChain = middleware.reversed().reduce({ action in
// Это последнее, что произойдет: изменим состояние через редьюсер и уведомим наблюдателей
self.reducer(&self.state, action)
self.notifyObservers()
}) { next, mw in
// Для каждого middleware создаем новый шаг в цепочке
return { action in
// Каждый middleware обрабатывает действие и затем передает его дальше
mw(action, self.state, next)
}
}
// Запускаем цепочку с первоначальным действием
middlewareChain(action)
}
Я попыталась наглядно отобразить, как будет обрабатываться действие, которое идет по цепочке middleware. Пунктирные стрелки - это подробный вариант, для начала пройдитесь по основным стрелкам. На что обратить внимание:
State во все middleware записывается при создании цепочки, а значит во время выполнения не меняется
Парамерт next в последнем middleware ссылается на reduser
Каждый middleware принимает действие (action) и либо меняет его либо прерывает цепочку действий
Дополним наше приложение, применив улучшенный Store
struct TaskListEnhancement: Middleware {
func process(action: TaskListAction, state: TaskListState, next: @escaping (TaskListAction) -> Void) {
switch action {
case .addTask(var task):
// Если подзаголовок не указан, добавляем стандартный подзаголовок
if task.subtitle == nil || task.subtitle?.isEmpty == true {
task.subtitle = "No description provided"
}
let enhancedAction = TaskListAction.addTask(task)
next(enhancedAction) // Передаем измененное действие дальше
default:
next(action) // Для других действий просто передаем действие дальше
}
}
}
struct TaskListValidation: Middleware {
func process(action: TaskListAction, state: TaskListState, next: @escaping (TaskListAction) -> Void) {
switch action {
case .addTask(let task):
guard !task.title.trimmingCharacters(in: .whitespaces).isEmpty else {
print("TaskListValidation - Попытка добавить задачу с пустым названием отклонена")
return // Отклоняем действие
}
next(action) // Если валидация пройдена, передаем действие дальше
default:
next(action) // Для других действий просто передаем действие дальше
}
}
}
TaskListView
struct TaskListView: View {
@State var store: Store<TaskListState, TaskListAction>
@State private var showSheet = false
@State private var newTaskTitle = ""
@State private var newTaskSubtitle = ""
var body: some View {
NavigationView {
ZStack {
TaskList(store: store)
AddTaskButton(showSheet: $showSheet)
}
.navigationTitle("Tasks")
.sheet(isPresented: $showSheet) {
NewTaskForm(
showSheet: $showSheet,
newTaskTitle: $newTaskTitle,
newTaskSubtitle: $newTaskSubtitle,
store: store
)
}
}
}
}
struct TaskList: View {
var store: Store<TaskListState, TaskListAction>
var body: some View {
List {
ForEach(store.state.filteredTasks) { task in
Text(task.title)
}
.onDelete { indexSet in
indexSet.map { store.state.tasks[$0].id }
.forEach { id in
store.dispatch(.removeTask(id))
}
}
}
.onAppear {
store.subscribe(observer: Observer { newState in
print("Состояние изменилось: \(newState.tasks)")
return .alive
})
}
}
}
struct AddTaskButton: View {
@Binding var showSheet: Bool
var body: some View {
VStack {
Spacer()
HStack {
Spacer()
Button(action: {
showSheet = true
}) {
Image(systemName: "plus")
.foregroundColor(.white)
.font(.system(size: 24))
.padding()
.background(Color.black)
.clipShape(Circle())
.shadow(radius: 10)
}
.padding()
}
}
}
}
struct NewTaskForm: View {
@Binding var showSheet: Bool
@Binding var newTaskTitle: String
@Binding var newTaskSubtitle: String
var store: Store<TaskListState, TaskListAction>
var body: some View {
VStack {
Text("Новая задача")
.font(.headline)
.padding()
TextField("Название задачи", text: $newTaskTitle)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding()
TextField("Подзаголовок задачи", text: $newTaskSubtitle)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding()
HStack {
Button("Отмена") {
showSheet = false
}
.padding()
Spacer()
Button("Добавить") {
let newTask = TaskModel(title: newTaskTitle, subtitle: newTaskSubtitle)
store.dispatch(.addTask(newTask))
// Очистка полей
newTaskTitle = ""
newTaskSubtitle = ""
showSheet = false
}
.padding()
}
.padding()
}
.padding()
}
}
TasksAlarmApp
@main
struct TasksAlarmApp: App {
var body: some Scene {
WindowGroup {
TaskListView(
store: Store(
initial: TaskListState(tasks: [
TaskModel(title: "Task 1"),
TaskModel(title: "Task 2")
]),
reducer: taskListReducer,
middleware: [
AnyMiddleware(TaskListValidation()),
AnyMiddleware(TaskListEnhancement())
]
)
)
}
}
}
Уровень сложности Store 🧠 🧠 🧠 🧠
Ну и последнее усложнение, и мы наконец-то приступим к сохранению данных в базу. Потоки. Да, этот шаг можно и опустить, но очень уж хочется дойти до более или менее полноценной архитектуры, простите...
Новый Store:
class Store<State, Action>: ObservableObject where State: Equatable {
@Published private(set) var state: State
private let reducer: (inout State, Action) -> Void
private var observers: Set<Observer<State>> = []
private var middleware: [AnyMiddleware<State, Action>]
private let queue = DispatchQueue(label: "Store queue", qos: .userInitiated)
init(
initial state: State,
reducer: @escaping (inout State, Action) -> Void,
middleware: [AnyMiddleware<State, Action>] = []
) {
self.reducer = reducer
self.state = state
self.middleware = middleware
}
func dispatch(_ action: Action) {
queue.sync {
let middlewareChain = self.middleware.reversed().reduce({ action in
self.reducer(&self.state, action)
self.notifyObservers()
}) { next, mw in
return { action in
mw.process(action: action, state: self.state, next: next)
}
}
middlewareChain(action)
}
}
func subscribe(observer: Observer<State>) {
queue.sync {
self.observers.insert(observer)
self.notify(observer)
}
}
private func notifyObservers() {
for observer in observers {
notify(observer)
}
}
private func notify(_ observer: Observer<State>) {
let state = self.state
observer.queue.async {
if observer.observe(state) == .dead {
self.queue.async {
self.observers.remove(observer)
}
}
}
}
}
Обновим Observer
final class Observer<State> {
let queue: DispatchQueue
let observe: (State) -> ObserverStatus
init(queue: DispatchQueue = .main,
observe: @escaping (State) -> ObserverStatus) {
self.queue = queue
self.observe = observe
}
}
Что нового:
Синхронизация операций
Асинхронное уведомление наблюдателей
Немного SwiftData
Middleware для работы со SwiftData:
struct TaskListPersistence: Middleware {
private var context: ModelContext
init(context: ModelContext) {
self.context = context
}
func process(action: TaskListAction, state: TaskListState, next: @escaping (TaskListAction) -> Void) {
switch action {
case .addTask(let newTask):
context.insert(newTask)
try? context.save()
next(.tasksLoaded(loadTasksFromDB()))
case .removeTask(let id):
if let taskToRemove = state.tasks.first(where: { $0.id == id }) {
context.delete(taskToRemove)
try? context.save()
next(.tasksLoaded(loadTasksFromDB()))
}
case .loadTasks:
let tasks = loadTasksFromDB()
next(.tasksLoaded(tasks))
default:
next(action)
}
}
private func loadTasksFromDB() -> [TaskModel] {
do {
let fetchDescriptor = FetchDescriptor<TaskModel>()
return try context.fetch(fetchDescriptor)
} catch {
print("Ошибка загрузки задач: \(error)")
return []
}
}
}
Обновим TaskModel, чтобы таски можно было сохранить в базу данных:
@Model
class TaskModel: Equatable, Identifiable {
@Attribute(.unique) var id = UUID()
var title: String
var subtitle: String?
var isCompleted = false
init(id: UUID = UUID(), title: String, subtitle: String? = nil, isCompleted: Bool = false) {
self.id = id
self.title = title
self.subtitle = subtitle
self.isCompleted = isCompleted
}
}
И соберем это в main:
@main
struct TasksAlarmApp: App {
let container: ModelContainer
let store: Store<TaskListState, TaskListAction>
init() {
self.container = try! ModelContainer(for: TaskModel.self)
self.store = Store(
initial: TaskListState(),
reducer: taskListReducer,
middleware: [
AnyMiddleware(TaskListValidation()),
AnyMiddleware(TaskListEnhancement()),
AnyMiddleware(TaskListPersistence(context: container.mainContext))
]
)
self.store.dispatch(.loadTasks)
}
var body: some Scene {
WindowGroup {
TaskListView(store: store)
}
}
}
Остальные изменения помелочи
enum TaskListAction {
case addTask(TaskModel)
case toggleTaskCompletion(UUID)
case removeTask(UUID)
case filterTasks(TaskListFilter)
case loadTasks
case tasksLoaded([TaskModel])
}
func taskListReducer(_ state: inout TaskListState, action: TaskListAction) {
switch action {
case .addTask(let task):
state.tasks.append(task)
case .toggleTaskCompletion(let id):
if let index = state.tasks.firstIndex(where: { $0.id == id }) {
state.tasks[index].isCompleted.toggle()
}
case .removeTask(let id):
state.tasks.removeAll { $0.id == id }
case .filterTasks(let filter):
state.filter = filter
case .tasksLoaded(let tasks):
state.tasks = tasks
// Это действие обрабатывается мидлваром
case .loadTasks:
break
}
}
struct TaskListView: View {
@ObservedObject var store: Store<TaskListState, TaskListAction>
@State private var showSheet = false
@State private var newTaskTitle = ""
@State private var newTaskSubtitle = ""
var body: some View {
NavigationView {
ZStack {
TaskList(store: store)
AddTaskButton(showSheet: $showSheet)
}
.navigationTitle("Tasks")
.sheet(isPresented: $showSheet) {
NewTaskForm(
showSheet: $showSheet,
newTaskTitle: $newTaskTitle,
newTaskSubtitle: $newTaskSubtitle,
store: store
)
}
}
}
}
struct TaskList: View {
@ObservedObject var store: Store<TaskListState, TaskListAction>
var body: some View {
List {
ForEach(store.state.filteredTasks) { task in
Text(task.title)
}
.onDelete { indexSet in
indexSet.map { store.state.tasks[$0].id }
.forEach { id in
store.dispatch(.removeTask(id))
}
}
}
.onAppear {
store.subscribe(observer: Observer { newState in
print("Состояние изменилось: \(newState.tasks)")
return .alive
})
}
}
}
struct AddTaskButton: View {
@Binding var showSheet: Bool
var body: some View {
VStack {
Spacer()
HStack {
Spacer()
Button(action: {
showSheet = true
}) {
Image(systemName: "plus")
.foregroundColor(.white)
.font(.system(size: 24))
.padding()
.background(Color.black)
.clipShape(Circle())
.shadow(radius: 10)
}
.padding()
}
}
}
}
struct NewTaskForm: View {
@Binding var showSheet: Bool
@Binding var newTaskTitle: String
@Binding var newTaskSubtitle: String
var store: Store<TaskListState, TaskListAction>
var body: some View {
VStack {
Text("Новая задача")
.font(.headline)
.padding()
TextField("Название задачи", text: $newTaskTitle)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding()
TextField("Подзаголовок задачи", text: $newTaskSubtitle)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding()
HStack {
Button("Отмена") {
showSheet = false
}
.padding()
Spacer()
Button("Добавить") {
let newTask = TaskModel(title: newTaskTitle, subtitle: newTaskSubtitle)
store.dispatch(.addTask(newTask))
// Очистка полей
newTaskTitle = ""
newTaskSubtitle = ""
showSheet = false
}
.padding()
}
.padding()
}
.padding()
}
}
Наш проект разросся и уже умеет добавлять, удалять и даже сохранять задачи. Мы сделали первый, но самый сложный шаг на пути к Flux.
Есть еще много других полезных структур для улучшения архитектуры проекта, они отлично ложаться на Flux, а может и произошли вместе с ним, в любом случае это тема для другой статьи
Лирическое заключение
Это моя первая в жизни статья! Я рада, что мне удалось ее дописать! Не судите строго, пожалуйста 🥹 Буду рада вашим вопросам 🙃 Конструктивная критика приветствуется 🤗