Reactive MVVM and the Coordinator Pattern Done Right

Reactive MVVM and the Coordinator Pattern Done Right
So, you’ve implemented an MVC architecture in your iOS apps multiple times, explored MVP as an alternative, heard something about VIPER , and (probably) liked MVVM.

Intro

So, you’ve implemented an MVC architecture in your iOS apps multiple times, explored MVP as an alternative, heard something about VIPER, and (probably) liked MVVM.

Now what? It’s time to take your MVVM knowledge to a whole new level — reactive MVVM with coordinators!

In this article, I will demonstrate how to implement an MVVM-Coordinator pattern with RxSwift, RxDataSources, RxAlamofire.

We will also use my own CocoaPod that provides the base implementation of ReactiveCoordinator, which is available in my GitHub repo.

The demo app uses a free Holiday API to get the list of countries and fetch last year’s holidays for a chosen one.

The source code of the project is available on GitHub.

Demo App

demo1

demo2

demo3

demo4

Project Structure

project

Quick Setup

To fetch holidays and countries successfully, we will need to obtain a free Holiday API key:

  1. Go to https://holidayapi.com.
  2. Press Get Started and create your account.
  3. Copy your API key.

Now, simply replace “PASTE YOUR API KEY HERE” in APIManager.swift with the key that you copied from the dashboard:

mport Foundation

class APIManager {
    
    ....
    
    lazy var apiKey: String = {
        return "PASTE YOUR API KEY HERE"
    }()
    
}

APIManager.swift

To finish the setup, navigate to the project’s directory and run pod install to install all the required dependencies:

pod install

Let’s Start

Coordinator

First, let’s take a look at the Coordinator implementation in the ReactiveCoordinator framework.

  • We specify the CoordinationResult to replace delegation between view controllers in the future.
  • The generic store<T> method is used to store our coordinators in the childCoordinators dictionary.
  • The generic release<T> method is responsible for deallocating unused coordinations.
  • The generic coordinate<T> method is used to provide navigation between screens.
  • We also have a mandatory start() method in which we will construct and connect view controllers with their view models.
import RxSwift

open class ReactiveCoordinator<ResultType>: NSObject {

    public typealias CoordinationResult = ResultType

    public let disposeBag = DisposeBag()
    private let identifier = UUID()
    private var childCoordinators = [UUID: Any]()

    private func store<T>(coordinator: ReactiveCoordinator<T>) {
        childCoordinators[coordinator.identifier] = coordinator
    }

    private func release<T>(coordinator: ReactiveCoordinator<T>) {
        childCoordinators[coordinator.identifier] = nil
    }

    @discardableResult
    open func coordinate<T>(to coordinator: ReactiveCoordinator<T>) -> Observable<T> {
        store(coordinator: coordinator)
        return coordinator.start()
            .do(onNext: { [weak self] _ in
                self?.release(coordinator: coordinator) })
    }

    open func start() -> Observable<ResultType> {
        fatalError("start() method must be implemented")
    }
}

ReactiveCoordinator.swift

Here is an example of how we use ReactiveCoordinator in our app. Suppose we have two Coordinators: AppCoordinator and HolidaysCoordinator. Both inherit from ReactiveCoordinator. The workflow:

  1. AppCoordinator fires its start() method.
  2. The coordinate(to: ) method is called, which coordinates to HolidaysCoordinator and fires its own start() method, which constructs and connects the HolidaysViewController and its ViewModel.
  3. As a result, we have HolidaysViewController presented on the screen.

Networking

We use RxAlamofire to create a base generic get<T> request.

APIClient.swift

class APIClient {
  .....

func get<T: BaseMappable>(urlString: String, parameters: [String: Any] = [:], success: @escaping (Int, T) -> (), failure: @escaping (String) -> ()) {
        
        var parameters = parameters
        parameters["key"] = APIManager.shared.apiKey
        
        guard let url = NSURL(string: urlString , relativeTo: self.baseURL as URL?) else {
            return
        }
        
        let urlString = url.absoluteString!
        
        _ = request(.get,
                    urlString,
                    parameters: parameters,
                    headers: nil)
            .validate(statusCode: 200..<300)
            .validate(contentType: ["application/json"])
            .responseJSON()
            .observeOn(MainScheduler.instance)
            .subscribe(onNext: { (response) in
                let statusCode = response.response?.statusCode
                let model = Mapper<T>().map(JSON: response.result.value as! [String : Any])
                success(statusCode!, model!)
            }, onError: { (error) in
                failure(error.localizedDescription)
            })
    }
}

APIClient.swift

Models

ObjectMapper is used to construct objects from JSON that we receive from the Holiday API.

Holidays.swift

import ObjectMapper

struct Holidays: Mappable {
    var holidays: [Holiday]?
    
    init?(map: Map) {}
    
    mutating func mapping(map: Map) {
        holidays <- map["holidays"]
    }
}

struct Holiday: Mappable {
    var name: String?
    var date: String?
    var country: String?
    var `public`: Bool?
    
    init?(map: Map) {}
    
    mutating func mapping(map: Map) {
        name     <- map["name"]
        date     <- map["date"]
        country  <- map["country"]
        `public` <- map["public"]
    }
}

Holidays.swift

Countries.swift

import ObjectMapper

struct Countries: Mappable {
    var countries: [Country]?
    
    init?(map: Map) {}
    
    mutating func mapping(map: Map) {
        countries <- map["countries"]
    }
}

struct Country: Mappable {
    var code: String?
    var name: String?
    var flag: String?
    
    init?(map: Map) {}
    
    mutating func mapping(map: Map) {
        code <- map["code"]
        name <- map["name"]
        flag <- map["flag"]
    }
}

Countries.swift

With that being set up, we are ready to start building our Coordinators.

AppCoordinator.swift

This class is responsible for setting up the rootViewController for the window.

In its start method, we specify that the rootViewController of the window is HolidaysViewController, embedded in a UINavigationController. Finally, we coordinate to HolidaysCoordinator:

import RxSwift
import ReactiveCoordinator

class AppCoordinator: ReactiveCoordinator<Void> {
    
    var window: UIWindow
    
    init(window: UIWindow) {
        self.window = window
    }
    
    override func start() -> Observable<Void> {
        
        let navigationController = UINavigationController(rootViewController: HolidaysViewController())
        
        let holidaysCoordinator = HolidaysCoordinator(rootViewController: navigationController.viewControllers[0])
        
        window.rootViewController = navigationController
        window.makeKeyAndVisible()
        
        return coordinate(to: holidaysCoordinator)
    }
}

AppCoordinator.swift

AppDelegate.swift

Here, we create and start our AppCoordinator:

import UIKit
import RxSwift

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
    
    var window: UIWindow?
    private var appCoordinator: AppCoordinator!
    private let disposeBag = DisposeBag()

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        
        window = UIWindow()
        
        appCoordinator = AppCoordinator(window: window!)
        appCoordinator.start()
            .subscribe()
            .disposed(by: disposeBag)
        
        return true
    }
}

AppDelegate.swift

We have successfully set up the initial flow of our app.

Holidays Module

This module displays a list of holidays if a country was chosen. Otherwise, it shows a placeholder message “Choose your country first”.

HolidaysCoordinator.swift

Here, similar to how we did it in the AppCoordinator, we set up HolidaysViewController and provide it with a HolidaysViewModel.

import RxSwift
import ReactiveCoordinator

class HolidaysCoordinator: ReactiveCoordinator<Void> {
    
    let rootViewController: UIViewController
    
    init(rootViewController: UIViewController) {
        self.rootViewController = rootViewController
    }
    
    override func start() -> Observable<Void> {
        
        let viewController = rootViewController as! HolidaysViewController
        let viewModel = HolidaysViewModel()
        viewController.viewModel = viewModel
        
        viewModel.selectedHoliday
            .subscribe({ [weak self] holidayItem in
                
                if let holiday = holidayItem.element {
                    self?.coordinateToHolidayDetail(with: holiday)
                }
                
            })
            .disposed(by: disposeBag)
        
        viewModel.chooseCountry
            .flatMap { [weak self] _ -> Observable<String?> in
                guard let `self` = self else { return .empty() }
                return self.coordinateToChooseCountry()
        }
        .filter { $0 != nil }
        .map { $0! }
        .bind(to: viewModel.selectedCountry)
        .disposed(by: disposeBag)
        
        return Observable.never()
    }
    
    // MARK: - Coordination
    private func coordinateToHolidayDetail(with holidayViewModel: HolidayViewModel) {
        let holidayDetailCoordinator = HolidayDetailCoordinator(rootViewController: rootViewController)
        holidayDetailCoordinator.viewModel = holidayViewModel
        coordinate(to: holidayDetailCoordinator)
    }
    
    private func coordinateToChooseCountry() -> Observable<String?> {
        let chooseCountryCoordinator = ChooseCountryCoordinator(rootViewController: rootViewController)
        return coordinate(to: chooseCountryCoordinator)
            .map { result in
                switch result {
                case .country(let country): return country
                case .cancel: return nil
                }
        }
    }
    
}

HolidaysCoordinator.swift

In the start() method, we subscribe to the selectedHoliday and chooseCountry properties of the view model.

When an event is emitted to these properties, (i.e. the user taps on the Choose Country button or selects a cell), the coordinator knows that it’s time to navigate.

HolidaysViewController.swift

Its sole responsibility is to layout the view, and bind UI elements to the HolidaysViewModel.

import PKHUD

import RxSwift
import RxCocoa
import RxDataSources

class HolidaysViewController: UIViewController {
    
    // MARK: - Lifecycle Methods
    override func viewDidLoad() {
        super.viewDidLoad()
        
        setupUI()
        setupNavItem()
        
        bindTableView()
        bindNavItem()
        bindHUD()
        bindVisibilityState()
        
        viewModel.fetchHolidays{ (errorMessage) in
            self.showMessage(errorMessage)
        }
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        setupNavBar()
    }
    
    // MARK: - Properties
    let disposeBag = DisposeBag()
    var viewModel: HolidaysViewModel!
    
    ....
    
}

// MARK: - Binding
extension HolidaysViewController {
    func bindTableView() {

        viewModel.holidays
            .bind(to: tableView.rx.items(cellIdentifier: "HolidayTableViewCell", cellType: HolidayTableViewCell.self)) { index, viewModel, cell in
                cell.viewModel = viewModel
            }
            .disposed(by: disposeBag)
        
        tableView.rx.modelSelected(HolidayViewModel.self)
            .bind(to: viewModel.selectedHoliday)
            .disposed(by: disposeBag)
        
        tableView.rx.itemSelected
            .subscribe(onNext: { (indexPath) in
                self.tableView.deselectRow(at: indexPath, animated: true)
            })
            .disposed(by: disposeBag)
    }
    
    func bindHUD() {
        
        viewModel.isLoading
            .subscribe(onNext: { [weak self] isLoading in
                isLoading ? self?.showProgress() : self?.hideProgress()
            })
            .disposed(by: disposeBag)
    }
    
    func bindNavItem() {
        chooseCountryItem.rx.tap
            .bind(to: viewModel.chooseCountry)
            .disposed(by: disposeBag)
        
        viewModel.selectedCountry
            .subscribe(onNext: { (country) in
                self.chooseCountryItem.title = country
            })
            .disposed(by: disposeBag)
    }
    
    func bindVisibilityState() {
        viewModel.selectedCountry
            .subscribe(onNext: { _ in
                self.tableView.isHidden = false
                self.chooseCountryLabel.isHidden = true
            })
            .disposed(by: disposeBag)
    }
    

}

// MARK: - UI Setup
extension HolidaysViewController {
    func setupUI() {
        overrideUserInterfaceStyle = .light
        self.view.backgroundColor = .white
        .....
    }
    
    func setupNavItem() {
        self.navigationItem.title = "Holidays"
        self.navigationItem.rightBarButtonItem = chooseCountryItem
    }
    
    func setupNavBar() {
        self.navigationController?
            .navigationBar
            .tintColor = .white
    }
}

HolidaysViewController.swift

Take a closer look at how we work with UITableView:

extension HolidaysViewController {
func bindTableView() {

        viewModel.holidays
            .bind(to: tableView.rx.items(cellIdentifier: "HolidayTableViewCell", cellType: HolidayTableViewCell.self)) { index, viewModel, cell in
                cell.viewModel = viewModel
            }
            .disposed(by: disposeBag)
        
        tableView.rx.modelSelected(HolidayViewModel.self)
            .bind(to: viewModel.selectedHoliday)
            .disposed(by: disposeBag)
        
        tableView.rx.itemSelected
            .subscribe(onNext: { (indexPath) in
                self.tableView.deselectRow(at: indexPath, animated: true)
            })
            .disposed(by: disposeBag)
}
  ......

HolidaysViewController.swift

First, we bind the holidays property of the viewModel to the tableView using RxDataSources. It’s super short and simple, and we don’t even have to use these lengthy UITableViewDelegate and UITableViewDataSource implementations!

Next, we bind the selection of a particular tableView cell to the viewModel’s selectedHoliday property. This way, we avoid the use of the didSelectRow(at: ) UITableViewDelegate method.

And that being done in just three lines — neat!

Lastly, we subscribe to the selection event to bring the background of the cell to its former state, by receiving the selected indexPath and calling the deselectRow(at: ) method.

HolidaysViewModel.swift

Here, all the magic happens. This view model is responsible for accepting input from the view and fetching holidays.

import RxCocoa

class HolidaysViewModel {
    private let disposeBag = DisposeBag()
    
    // MARK: - Actions
    let isLoading = BehaviorSubject<Bool>(value: false)
    let selectedCountry = PublishSubject<String>()
    let selectedHoliday = PublishSubject<HolidayViewModel>()
    let chooseCountry = PublishSubject<Void>()
    
    // MARK: - Table View Model and Data Source
    var holidays = BehaviorSubject<[HolidayViewModel]>(
        value: []
    )
    
    // MARK: - API Call
    func fetchHolidays(onError: @escaping (String) -> ()) {
        
        self.selectedCountry
            .subscribe(onNext: { [weak self] (country) in
                guard let `self` = self else { return }
                
                self.isLoading.onNext(true)
                HolidaysService.shared.getHolidays(country: country, success: { (code, holidays) in
                    self.isLoading.onNext(false)
                    
                    let holidayItems = holidays.holidays!.compactMap { HolidayViewModel(holiday: $0)
                    }
                    self.holidays.onNext(holidayItems)
                    
                }) { (error) in
                    self.isLoading.onNext(false)
                    onError(error)
                }
            })
            .disposed(by: disposeBag)
        
    }
}

HolidaysViewModel.swift

HolidayTableViewCell.swift

The cell has its own HolidayViewModel, on the assignment of which we call the configure() method to update the cell’s UI with the Model.


class HolidayTableViewCell: UITableViewCell {
    
    // MARK: - Initialization
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style,
                   reuseIdentifier: reuseIdentifier)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        setupUI()
    }
    // MARK: - Properties
    var viewModel: HolidayViewModel! {
        didSet {
            self.configure()
        }
    }
    
    lazy var holidayTitleLabel: UILabel = {
        let label = UILabel()
        return label
    }()
    
}

// MARK: - Configuration
extension HolidayTableViewCell {
    private func configure() {
        holidayTitleLabel.text = viewModel.title
    }
}

....

HolidayTableViewCell.swift

HolidayViewModel.swift

Used in HolidayTableViewCell to represent its properties:

import Foundation

struct HolidayViewModel {
    let title: String
    let date: String
    let country: String
    let isPublic: Bool
    
    init(holiday: Holiday) {
        
        self.title = holiday.name!
        self.date = holiday.date!
        self.country = holiday.country!
        self.isPublic = holiday.public!
    }
}

HolidayViewModel.swift

ChooseCountry Module

This module displays a list of countries. On the selection of a particular country, the ChooseCountryViewController is dismissed, forwarding the selected country back to HolidaysViewModel.

ChooseCountryCoordinator.swift

Here is where we will use our CoordinationResult. When we tap on the Close button, HolidaysCoordinator receives nil. When a cell is selected, it receives the selected country:

import ReactiveCoordinator

enum ChooseCountryCoordinationResult {
    case country(String)
    case cancel
}

class ChooseCountryCoordinator: ReactiveCoordinator<ChooseCountryCoordinationResult> {
    
    private let rootViewController: UIViewController
    
    init(rootViewController: UIViewController) {
        self.rootViewController = rootViewController
    }
    
    override func start() -> Observable<CoordinationResult> {
        let viewController = ChooseCountryViewController()
        let navigationController = UINavigationController(rootViewController: viewController)
        
        let viewModel = ChooseCountryViewModel()
        viewController.viewModel = viewModel
        
        let country = viewModel.selectedCountry.map { CoordinationResult.country($0) }
        let cancel = viewModel.didClose.map { _ in
            CoordinationResult.cancel
        }
        
        rootViewController.present(navigationController, animated: true, completion: nil)
        
        return Observable.merge(country, cancel)
            .take(1)
            .do(onNext: { _ in
                viewController.dismiss(animated: true, completion: nil)
            })
    }
    
}

ChooseCountryCoordinator.swift

ChooseCountryViewController.swift

As before, the controller simply displays the UI and sends action events to the view model:

import RxSwift
import RxCocoa

class ChooseCountryViewController: UIViewController {
    
    // MARK: - Lifecycle Methods
    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
        setupNavItem()
        
        bindTableView()
        bindSearchBar()
        bindCloseItem()
        bindHUD()
        
        viewModel.fetchCountries { [weak self] (errorMessage) in
            self?.showMessage(errorMessage)
        }
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        setupNavBar()
    }

    // MARK: - Properties
    let disposeBag = DisposeBag()
    var viewModel: ChooseCountryViewModel!
    ....
}

// MARK: - Binding
extension ChooseCountryViewController {
    func bindTableView() {
        viewModel.filteredCountries
            .bind(to: tableView.rx.items(cellIdentifier: "CountryTableViewCell", cellType: CountryTableViewCell.self)) { (index, viewModel, cell) in
                cell.viewModel = viewModel
            }
            .disposed(by: disposeBag)
        
        tableView.rx.modelSelected(CountryViewModel.self)
            .map { $0.code }
            .bind(to: viewModel.selectedCountry)
            .disposed(by: disposeBag)
    }
    
    func bindCloseItem() {
        closeItem.rx.tap
            .bind(to: viewModel.didClose)
            .disposed(by: disposeBag)
    }
    
    func bindSearchBar() {
        searchBar.rx.text
            .orEmpty
            .bind(to: viewModel.searchText)
            .disposed(by: disposeBag)
    }
    
    func bindHUD() {
        viewModel.isLoading
            .subscribe(onNext: { [weak self] isLoading in
                isLoading ? self?.showProgress() : self?.hideProgress()
            })
            .disposed(by: disposeBag)
    }
}

.....

ChooseCountryViewController.swift

CountryTableViewCell.swift

Has identical functionality as HolidayTableViewCell:

import UIKit

class CountryTableViewCell: UITableViewCell {
    
    // MARK: - Initialization
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        setupUI()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    // MARK: - Properties
    var viewModel: CountryViewModel! {
        didSet {
            self.configure()
        }
    }
    
    .....
 
}

// MARK: - Configuration
extension CountryTableViewCell {
    func configure() {
        countryNameLabel.text = viewModel.name
    }
}
.....

CountryTableViewCell.swift

ChooseCountryViewModel.swift

Here, we perform two important tasks: fetching countries and filtering based on the text from a searchBar:

import RxCocoa

final class ChooseCountryViewModel {
    private let disposeBag = DisposeBag()
    
    // MARK: - Actions
    let didClose = PublishSubject<Void>()
    let selectedCountry = PublishSubject<String>()
    
    let isLoading = BehaviorSubject<Bool>(value: false)
    let searchText = PublishSubject<String>()
    
    // MARK: - Table View Model & Data Source
    let fetchedCountries = BehaviorSubject<[CountryViewModel]>(value: [])
    let filteredCountries = BehaviorSubject<[CountryViewModel]>(value: [])
    
    // MARK: - API Call
    func fetchCountries(onError: @escaping (String) -> ()) {
        
        self.isLoading.onNext(true)
        CountriesService.shared.getCountries(success: { [weak self] (code, countries) in
            guard let `self` = self else { return }
            
            self.isLoading.onNext(false)
            
            let countryItems = countries.countries!.compactMap {
                CountryViewModel(country: $0)
            }
            
            self.fetchedCountries.onNext(countryItems)
            self.filteredCountries.onNext(countryItems)
            
            self.bindSearchToModel()
        }) { [weak self] (error) in
            guard let `self` = self else { return }
            
            self.isLoading.onNext(false)
            onError(error)
        }
    }
    
    func bindSearchToModel() {
        self.searchText.subscribe(onNext: { [weak self] (text) in
            guard let `self` = self else { return }
            
            switch text.isEmpty {
            case false:
                let countries = try! self.fetchedCountries.value().filter {
                    $0.name.range(of: text, options: .caseInsensitive) != nil
                }
                self.filteredCountries.onNext(countries)
            case true:
                self.filteredCountries.onNext(try! self.fetchedCountries.value())
            }
           
        })
            .disposed(by: disposeBag)
        
    }
}

ChooseCountryViewModel.swift

CountryViewModel.swift

Represents a cell containing the name of a country and its code:

import RxSwift
import RxCocoa

struct CountryViewModel {
    let code: String
    let name: String
    
    init(country: Country) {
        self.code = country.code!
        self.name = country.name!
    }
}

CountryViewModel.swift

Holiday Detail Module

This module displays details of a particular holiday, showing its title, date, country, and isPublic status.

HolidayDetailCoordinator.swift

Builds up the HolidaysViewController and provides it with a HolidayViewModel:

import ReactiveCoordinator

class HolidayDetailCoordinator: ReactiveCoordinator<Void> {
    
    private let rootViewController: UIViewController
    public var viewModel: HolidayViewModel!
    
    init(rootViewController: UIViewController) {
        self.rootViewController = rootViewController
    }
    
    override func start() -> Observable<Void> {
        let viewController = HolidayDetailViewController()
        viewController.viewModel = viewModel
        
        rootViewController.navigationController?
            .pushViewController(viewController, animated: true)
        return Observable.empty()
    }
    
}

HolidayDetailCoordinator.swift

HolidayDetailViewController.swift

Does exactly the same job as other view controllers:

    
    // MARK: - Lifecycle Methods
    override func viewDidLoad() {
        super.viewDidLoad()
        ....
        bindViewModel()
    }

    // MARK: - Properties
    let disposeBag = DisposeBag()
    var viewModel: HolidayViewModel!
  
  .......
}

// MARK: - Binding
extension HolidayDetailViewController {
  
    func bindViewModel() {
        self.holidayNameLabel.text = viewModel.title
        self.holidayDateLabel.text = viewModel.date
        self.holidayCountryLabel.text = viewModel.country
        self.isHolidayPublicLabel.text = viewModel.isPublic ? "True" : "False"
    }
}

....

HolidayDetailViewController.swift

Wrapping Up

Now, with the Reactive MVVM-Coordinator design pattern being successfully implemented, we see the following benefits:

  1. View controllers are only responsible for laying out their views and sending actions to view models, making them slim and concise. Even if you see a 300-line view controller, you know it primarily has the methods for setting up constraints, performing layout, and binding to a view model.
  2. Coordinators listen for the navigation events in view models that are emitted by view controllers, thereby clearly separating responsibilities between them.
  3. View models handle all the business logic of the app and are UIKit-independent, which makes it easy to write unit tests for them.
  4. View controllers don’t know about each other, all navigation is handled inside Coordinators.
  5. The code is neat because we have used RxDataSources, so there are no lengthy delegate methods.
  6. If we find out that the wrong screen is being presented, we know that the problem is inside a Coordinator. If the view is not displayed the way we want, we jump straight into a view controller. If an API request fails, or, for example, filtering doesn’t work as expected, we know to look for issues inside a view model.

You could learn how others have implemented the reactive MVVM-Coordinator pattern from the following links:

If you are interested in how to implement a VIPER architecture in your app, check out my other article where I show how I did it in a simple client-server app.

Suggest:

Vue JS 3 Reactivity Fundamentals - Composition API

ReactJS & React Hooks Tutorial For Beginners

Uploading Videos Using Multer - Let's Build A Youtube Clone with ReactJS, NodeJS, MySQL

TypeWriter Effect In ReactJS Tutorial

How to Learn ReactJS Really Quick As A Beginner [2020]

Socket.io + ReactJS Tutorial | Learn Socket.io For Beginners