Nuxtstop

For all things nuxt.js

Decoupling the logic from the UI in React using the BLoC pattern

4 0

Software evolves and changes in time, and sooner or later this means that the library or framework used for the UI could be changed as well. The amount of work and refactoring needed when switching to another UI library or framework depends on how hard coupled is the code and business logic to it.

Writing React functional components can be fast and convenient, using hooks for the local state, business logic, etc. There are even libraries that makes it easier to write tests for our components, where the logic of the component can be tested. However, if in the future we were to change to another library, we would need to completely refactor all the components and the tests, spending more time on it than if we had the logic separated from the UI (in this case the React functional component).

In this article, I show one way of decoupling the logic from the UI using React functional components and MobX.

Introduction to BLoC pattern.

BLoC stands for Business Logic Component, and was introduced by Google on the DartConf 2018. The initial idea behind the BLoC pattern, was to decouple the business logic from the component so it could be reused between Flutter and Angular Dart.
The idea of separation of concerns between the view and it's logic has been around for many years, with other patterns like for example MVC (Model View Controller), MVP (Model View Presenter) and MVVM (Model View View-Model). BLoC would be the equivalent to the Controller in MVC, Presenter in MVP, and View-Model in MVVM. For a component based library like React, we would be using BLoC as the pattern for separating the business logic from the component UI. Some of the benefits that we would achieve by using this pattern are:

  1. Better testability
    It's easier to write tests only for the business logic of the component. Also it's great for TDD.

  2. Components and screens logic become UI library/framework agnostic
    Switching libraries becomes so much easier.

  3. Reuse the BLoC in different components
    A BLoC could be reused in components that share the same logic but have a different UI, not only for Web but also for React Native.

  4. Extend the BLoC for similar components
    BLoCs could extend other BLoCs that share the same base logic but add more features.

  5. Cleaner code
    The BLoC contains all the business logic and the Functional Component is only responsible of UI logic, like adding/removing CSS classes, conditionally rendering elements, subscribing to events and notifying the BLoC, etc. Which makes the component more readable and "thinner" (specially on components with more complex business logic)

Implementing the BLoC pattern with MobX

MobX is a state management library that brings nonintrusive and transparent reactive functional programming to a standard JavaScript class. What this means is that when applied to normal JavaScript Classes, it makes properties and functions reactive without changing the way they are used. This is great, because it means that all the business logic is in a normal JavaScript class and the coupling to MobX is loose, allowing for an easier change of library if needed in the future.
MobX has bindings for the most popular libraries/frameworks like, React, Vue, Angular and Preact, so switching between any these libraries in the UI would not require any change in the BLoC.

The basic concepts of MobX are:

  • observable: Holds and tracks a state value and informs of any change to it's subscribers
  • computed: Returns a derived value from other states, which are being tracked so it can automatically recompute and inform of changes to it's subscribers
  • actions: Used to update the observables (state)
  • observer: Subscribes the Component to the observables and computed in the BLoC, making it rerender on a change.

The BLoC holds all the logic, properties and state of the component. The component sends events to the BLoC by calling a method (that would be normally internally configured as action if the method changes the state) and gets notified of changes through the component's observer that is subscribed to the BLoC's observables and computed properties, that hold the state.

use-bloc diagram

MobX has more advanced features like reactions, which are basically subscriptions to observables or computed, that can be useful for components or screens with more complex business logic.

Installing MobX

Installing MobX requires 2 libraries, the mobx library which it's used in the BLoC to setup the observables, computed, actions, etc. and the UI bindings library that "glues" the BLoC to the component, which in this case since we are using functional components would be mobx-react-lite.

npm i mobx mobx-react-lite

Using BLoC with React hooks

With this pattern, we want the BLoC object to be instantiated and kept durning the life of the component, updated on a rerender (or re-created), and disposed automatically (if needed) of any subscriptions, timers, references, etc. when the component it's unmounted.
For this we can use the useBloc custom hook that I have published as a NPM package.
To install it: npm i use-bloc

It has 3 parameters:

  • First, the BLoC constructor
  • Second, the BLoC parameters (props)
  • Third, an optional array of properties from the params that will re-create the BLoC (This will normally be params that are used for the state)

Example

Let's create a simple Checkbox component that has an isChecked state, an optional label with some text to show and an optional onChange callback.
For this example we would be using TypeScript.

Apart from the libraries mentioned above it uses BlocInterface with the optional methods that useBloc expects:

BlocInterface (bloc-interface.d.ts)

type BlocInterface<P> = {
  dispose?: () => void;
  updateParams?: (params: P) => void;
} & object;
Enter fullscreen mode Exit fullscreen mode

It also uses a global TypeScript generic type for defining the default properties:

Global types (global.d.ts)

From TypeScript globals.md

// Returns the optional keys of T as a union of string literals
declare type OptionalKeys<T> = Exclude<
  {
    [K in keyof T]: T extends Record<K, T[K]> ? never : K;
  }[keyof T],
  undefined
>;

// Makes all optional properties in T required
declare type OptionalParams<T> = Required<Pick<T, OptionalKeys<T>>>;

// Returns a Readonly type with all optional properties in T required
declare type OptionalDefaults<T> = Readonly<OptionalParams<T>>;

// Returns a Readonly type with all optional properties in T required excluding keys from K
declare type OptionalDefaultsPartial<T, K extends keyof T> = Omit<
  OptionalDefaults<T>,
  K
>;

Enter fullscreen mode Exit fullscreen mode

BLoC (checkbox-bloc.ts):

import { action, makeObservable, observable } from 'mobx';
import { BlocInterface } from './bloc-interface';

type OnChange = (checked: boolean) => void;

export interface CheckboxParams {
  checked?: boolean;
  label?: string;
  onChange?: OnChange;
}

const defaults: OptionalDefaultsPartial<CheckboxParams, 'onChange'> = {
  checked: false,
  label: '',
};

class CheckboxBloc implements BlocInterface<CheckboxParams> {
  isChecked: boolean;
  label: string = defaults.label;
  onChange?: OnChange;
  constructor(params: CheckboxParams) {
    this.isChecked = params.checked ?? defaults.checked;
    this.updateParams(params);

    makeObservable(this, {
      isChecked: observable,
      setChecked: action,
    });
  }

  updateParams(params: CheckboxParams) {
    this.label = params.label ?? defaults.label;
    this.onChange = params.onChange;
  }

  setChecked(checked: boolean) {
    this.isChecked = checked;
    if (this.onChange) {
      this.onChange(checked);
    }
  }
}

export default CheckboxBloc;
Enter fullscreen mode Exit fullscreen mode

Component (checkbox.tsx)

import React from 'react';
import CheckboxBloc, { CheckboxParams } from './checkbox-bloc';
import { useBloc } from 'use-bloc';
import { observer } from 'mobx-react-lite';

const Checkbox = observer((props: CheckboxParams) => {
  const bloc = useBloc(CheckboxBloc, props, ['checked']);
  return (
    <label>
      <input
        type="checkbox"
        checked={bloc.isChecked}
        onChange={(e) => bloc.setChecked(e.target.checked)}
      />
      {bloc.label}
    </label>
  );
});

export default Checkbox;
Enter fullscreen mode Exit fullscreen mode

As we can see, the Checkbox component is wrapped into an observer, effectively subscribing the component to the observable and computed changes, which would cause a rerender.

The isChecked observable (state) from the bloc instance is passed into the input checked attribute.
When the onChange event is triggered, it calls the setChecked action in the bloc with the new value, which updates the isChecked observable, causing the Checkbox component to rerender updating the checked attribute.

A change to the label prop would also cause a rerender, and useBloc would call updateParams in the CheckboxBloc's instance updating it's label property from the updated props, so when the JSX is built, it would use the updated label from the bloc instance.

If the checked prop is updated, it would also cause a rerender, but since this prop was used in the 3rd parameter of useBloc, it would cause the CheckboxBloc instance to be re-created with the new checked value.

Conclusions

Decoupling the business logic from the component might seem like extra work and additional libraries to install and learn, but the benefits in the long run are usually greater.

I find that MobX simplifies the implementation of the BLoC pattern making the state of the component reactive by just calling one method. The learning curve is easy as it's just a few concepts to understand.

It's great for TDD when defining the BLoC behaviour as you don't need to think about the component UI structure before writing the test, only the required behaviour.