DEV Community

Cover image for Review of state management in React: getting started with an MVC example
Cole Li
Cole Li

Posted on • Updated on

Review of state management in React: getting started with an MVC example

To develop a good React app, doing good state management is necessary. Today in React, we have kinds of practices about state management and some of them have ended up as widely-accepted libraries. Though, there is no clear winner yet and new wheels are still being invented actively, which means none of the widely-accepted libraries is obviously better than the rest in the view of the user devs. And, it drives me to think about 2 questions:

  1. How good are today's widely-accepted libraries of state management in React?
  2. What does a better library of state management in React look like?

To answer the question #1, I would find a most commonly used way of doing state management before any library of state management emerged to build a complicated enough example module as a baseline. Then, for each today's widely-accepted library, I rebuild the same example module with it and review how good it is in comparison with the baseline.

To answer the question #2, I would try best to think up a way of doing state management by making the best of today's widely-accepted libraries based on the answer to the question #1. But at this stage, only the idea is described, by which I wish the posibility for better ways gets sown.

To complete the work, a series of articles entitled with 'Review of state management in React: ...' are getting written. And, this article, as the starting of the series, states the introduction as above and the former part of the answer to the question #1 as below.

Table of contents

Model-view-controller(MVC) pattern

Since MVC pattern was formally introduced, it along with its variants has been dominating the engineering method of developing interactive systems. And, it says:

Models are those components of the system application that actually do the work (simulation of the application domain). They are kept quite distinct from views, which display aspects of the models. Controllers are used to send messages to the model, and provide the interface between the model with its associated views and the interactive user interface devices (e.g., keyboard, mouse).

Overview of MVC pattern

As models simulate the app domain, the states of models can directly represent the states of the app, which indicates MVC pattern is doing state management.

So, I would select MVC pattern as the previously mentioned 'most commonly used way of doing state management before any library of state management emerged' to build the example module.

Requirement of the example module

To make effective comparisons, the example module should be complicated enough. It should consist of at least 2 components with multiple related states handling user interactions to a certain degree. Though, to avoid getting lost in details, it should also not be too complicated. Then, a composite clock looks appropriate.

Demonstration of the composite clock

A composite clock is an interactive module that has 2 components, an analogue clock and a digital clock. The 2 child clocks always tick synchronously and can be set to new time by users. The analogue one can have its minute hand dragged. The digital one can have its text edited.

Although it's doable to use single big shared state for this example module, it's not always a good idea to use single big shared state for a real-world app because it brings poor maintainability of quality attributes(ISO/IEC 9126-1:2001). So, to closely emulate real-world situations, multiple related states are used here.

Then, there would be 3 related states seperately for the analogue clock, the digital clock and time itself. The state of time keeps a timestamp for the whole module. The states of the child clocks derive display data from the timestamp and accept user input data for setting the timestamp.

Relation of the 3 states

Example module built with MVC pattern

Now, let me build the baseline example module with MVC pattern. Here, create-react-app is used to initialize the React app. The option --template typescript is used to enable TypeScript:

$ npx create-react-app 01-getting-started-with-an-mvc-example --template typescript
# ...
$ cd 01-getting-started-with-an-mvc-example
Enter fullscreen mode Exit fullscreen mode

The version of CRA in use is 5.0.1 and the generated directory structure looks as follows:

$ tree -I node_modules
.
├── README.md
├── package-lock.json
├── package.json
├── public
│   ├── favicon.ico
│   ├── index.html
│   ├── logo192.png
│   ├── logo512.png
│   ├── manifest.json
│   └── robots.txt
├── src
│   ├── App.css
│   ├── App.test.tsx
│   ├── App.tsx
│   ├── index.css
│   ├── index.tsx
│   ├── logo.svg
│   ├── react-app-env.d.ts
│   ├── reportWebVitals.ts
│   └── setupTests.ts
└── tsconfig.json

2 directories, 19 files
Enter fullscreen mode Exit fullscreen mode

Then, src/App.tsx is cleared for later use:

// src/App.tsx
import { FC } from 'react';

const App: FC = () => {
  return null;
};

export default App;
Enter fullscreen mode Exit fullscreen mode

Following files are unused so removed:

$ rm src/App.css src/App.test.tsx src/logo.svg
Enter fullscreen mode Exit fullscreen mode

Also, to help with time parsing and formating, date-fns is installed:

$ npm i date-fns
Enter fullscreen mode Exit fullscreen mode

The example module, the composite clock, would be all placed in src/CompositeClock. To match the 3 requried states, there would be 3 models, TimeModel, AnalogueModel and DigitalModel. They provide methods for changing and getting their states, and emit events for subscribers on these states changed. Also, they fulfill the relation of the 3 states as required.

And for controllers and views, TimeModel has none, AnalogueModel has AnalogueView and AnalogueController, DigitalModel as DigitalView and DigitalController. Then, all these parts are glued together by CompositeView and CompositeController to fulfill the functionality.

Relation of parts in the composite clock built with MVC pattern

The 3 models are coded as follows:

// src/CompositeClock/TimeModel.ts
import { EventEmitter } from 'events';

export interface TimeState {
  timestamp: number;
}

export class TimeModel extends EventEmitter {
  static readonly EVENTS = {
    TIMESTAMP_CHANGED: 'timestamp-changed',
  } as const;

  private timestamp: number;

  constructor(timestamp?: number) {
    super();
    this.timestamp = timestamp ?? Date.now();
  }

  getState(): TimeState {
    return {
      timestamp: this.timestamp,
    };
  }

  changeTimestamp(timestamp: number) {
    this.timestamp = timestamp;
    this.emit(TimeModel.EVENTS.TIMESTAMP_CHANGED);
  }
}
Enter fullscreen mode Exit fullscreen mode
// src/CompositeClock/AnalogueModel.ts
import { EventEmitter } from 'events';
import { TimeModel } from './TimeModel';

const TWO_PI = 2 * Math.PI;

export interface AnalogueAngles {
  hour: number;
  minute: number;
  second: number;
}

export interface AnalogueState {
  displayAngles: AnalogueAngles;
  isEditMode: boolean;
  editModeAngles: AnalogueAngles;
}

export class AnalogueModel extends EventEmitter {
  static readonly EVENTS = {
    DISPLAY_ANGLES_CHANGED: 'display-angles-changed',
    IS_EDIT_MODE_CHANGED: 'is-edit-mode-changed',
    EDIT_MODE_ANGLES_CHANGED: 'edit-mode-angles-changed',
  } as const;

  private timeModel: TimeModel;
  private displayAngles: AnalogueAngles;
  private isEditMode: boolean;
  private editModeAngles: AnalogueAngles;

  constructor(timeModel: TimeModel) {
    super();
    this.timeModel = timeModel;
    this.displayAngles = this.calcDisplayAngles();
    this.isEditMode = false;
    this.editModeAngles = { ...this.displayAngles };
    this.timeModel.addListener(TimeModel.EVENTS.TIMESTAMP_CHANGED, () => this.syncDisplayAngles());
  }

  getState(): AnalogueState {
    return {
      displayAngles: this.displayAngles,
      isEditMode: this.isEditMode,
      editModeAngles: this.editModeAngles,
    };
  }

  calcDisplayAngles(): AnalogueAngles {
    const d = new Date(this.timeModel.getState().timestamp);
    return {
      hour: ((d.getHours() % 12) / 12) * TWO_PI + (d.getMinutes() / 60) * (TWO_PI / 12),
      minute: (d.getMinutes() / 60) * TWO_PI + (d.getSeconds() / 60) * (TWO_PI / 60),
      second: (d.getSeconds() / 60) * TWO_PI,
    };
  }

  syncDisplayAngles(): void {
    this.displayAngles = this.calcDisplayAngles();
    this.emit(AnalogueModel.EVENTS.DISPLAY_ANGLES_CHANGED);
  }

  enterEditMode(): void {
    if (this.isEditMode) return;
    this.isEditMode = true;
    this.editModeAngles = { ...this.displayAngles };
    this.emit(AnalogueModel.EVENTS.IS_EDIT_MODE_CHANGED);
  }

  exitEditMode(submit: boolean = true): void {
    if (!this.isEditMode) return;
    this.isEditMode = false;
    if (submit) {
      const d = new Date(this.timeModel.getState().timestamp);
      d.setHours(
        Math.floor((this.editModeAngles.hour / TWO_PI) * 12) + 12 * Math.floor(d.getHours() / 12)
      );
      d.setMinutes((this.editModeAngles.minute / TWO_PI) * 60);
      d.setSeconds((this.editModeAngles.second / TWO_PI) * 60);
      this.timeModel.changeTimestamp(d.getTime());
    }
    this.emit(AnalogueModel.EVENTS.IS_EDIT_MODE_CHANGED);
  }

  changeEditModeMinuteAngle(minuteAngle: number): void {
    this.editModeAngles.minute = (minuteAngle + TWO_PI) % TWO_PI;
    this.editModeAngles.hour =
      (Math.floor((this.editModeAngles.hour / TWO_PI) * 12) + minuteAngle / TWO_PI) * (TWO_PI / 12);
    this.emit(AnalogueModel.EVENTS.EDIT_MODE_ANGLES_CHANGED);
  }
}
Enter fullscreen mode Exit fullscreen mode
// src/CompositeClock/DigitalModel.ts
import { format, isMatch, parse } from 'date-fns';
import { EventEmitter } from 'events';
import { TimeModel } from './TimeModel';

export interface DigitalState {
  displayText: string;
  isEditMode: boolean;
  editModeText: string;
}

export class DigitalModel extends EventEmitter {
  static readonly EVENTS = {
    DISPLAY_TEXT_CHANGED: 'display-text-changed',
    IS_EDIT_MODE_CHANGED: 'is-edit-mode-changed',
    EDIT_MODE_TEXT_CHANGED: 'edit-mode-text-changed',
  } as const;

  static readonly FORMAT = 'HH:mm:ss';

  private timeModel: TimeModel;
  private displayText: string;
  private isEditMode: boolean;
  private editModeText: string;

  constructor(timeModel: TimeModel) {
    super();

    this.timeModel = timeModel;
    this.displayText = this.calcDisplayText();
    this.isEditMode = false;
    this.editModeText = this.displayText;

    this.timeModel.addListener(TimeModel.EVENTS.TIMESTAMP_CHANGED, () => this.syncDisplayText());
  }

  getState(): DigitalState {
    return {
      displayText: this.displayText,
      isEditMode: this.isEditMode,
      editModeText: this.editModeText,
    };
  }

  calcDisplayText(): string {
    return format(this.timeModel.getState().timestamp, DigitalModel.FORMAT);
  }

  syncDisplayText(): void {
    this.displayText = this.calcDisplayText();
    this.emit(DigitalModel.EVENTS.DISPLAY_TEXT_CHANGED);
  }

  enterEditMode(): void {
    if (this.isEditMode) return;
    this.isEditMode = true;
    this.editModeText = this.displayText;
    this.emit(DigitalModel.EVENTS.IS_EDIT_MODE_CHANGED);
  }

  exitEditMode(submit: boolean = true): void {
    if (!this.isEditMode) return;
    this.isEditMode = false;
    if (submit && this.isEditModeTextValid()) {
      this.timeModel.changeTimestamp(
        parse(this.editModeText, DigitalModel.FORMAT, this.timeModel.getState().timestamp).getTime()
      );
    }
    this.emit(DigitalModel.EVENTS.IS_EDIT_MODE_CHANGED);
  }

  changeEditModeText(editModeText: string): void {
    this.editModeText = editModeText;
    this.emit(DigitalModel.EVENTS.EDIT_MODE_TEXT_CHANGED);
  }

  isEditModeTextValid(): boolean {
    return isMatch(this.editModeText, DigitalModel.FORMAT);
  }
}
Enter fullscreen mode Exit fullscreen mode

And, the controllers and views are coded as follows:

// src/CompositeClock/AnalogueView.tsx
import { FC, useEffect, useState } from 'react';
import { AnalogueController } from './AnalogueController';
import { AnalogueModel } from './AnalogueModel';
import styles from './AnalogueView.module.css';

interface Props {
  className?: string;
  model: AnalogueModel;
  controller: AnalogueController;
}

export const AnalogueView: FC<Props> = ({ className, model, controller }) => {
  const [{ displayAngles, isEditMode, editModeAngles }, setState] = useState(model.getState());

  const angles = isEditMode ? editModeAngles : displayAngles;

  useEffect(() => {
    [
      AnalogueModel.EVENTS.DISPLAY_ANGLES_CHANGED,
      AnalogueModel.EVENTS.IS_EDIT_MODE_CHANGED,
      AnalogueModel.EVENTS.EDIT_MODE_ANGLES_CHANGED,
    ].forEach((event) => {
      model.addListener(event, () => setState(model.getState()));
    });
  }, [model]);

  useEffect(() => {
    window.addEventListener('keydown', controller.onKeyDown);
    return () => window.removeEventListener('keydown', controller.onKeyDown);
  }, [controller]);

  return (
    <div
      className={`${className ?? ''} ${styles.root} ${isEditMode ? styles.editMode : ''}`}
      onMouseLeave={controller.onMouseLeave}
      onMouseUp={controller.onMouseUp}
      onMouseMove={controller.onMouseMove}
    >
      <div className={styles.axis} />
      <div
        className={`${styles.hand} ${styles.hour}`}
        style={{ transform: `rotateZ(${angles.hour}rad)` }}
      />
      <div
        className={`${styles.hand} ${styles.minute}`}
        style={{ transform: `rotateZ(${angles.minute}rad)` }}
        onMouseDown={controller.onMinuteHandMouseDown}
      />
      <div
        className={`${styles.hand} ${styles.second}`}
        style={{ transform: `rotateZ(${angles.second}rad)` }}
      />
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode
/* src/CompositeClock/AnalogueView.module.css */
.root {
  margin: 12px;
  padding: 8px;
  width: 160px;
  height: 160px;
  border-radius: 100%;
  border: 1px solid black;
  position: relative;
}

.axis {
  position: absolute;
  background-color: black;
  left: 47.5%;
  top: 47.5%;
  width: 5%;
  height: 5%;
  border-radius: 100%;
}

.hand {
  position: absolute;
  background-color: black;
  transform-origin: bottom center;
}

.hand.hour {
  left: 48.5%;
  top: 25%;
  height: 25%;
  width: 3%;
}

.hand.minute {
  left: 49%;
  top: 10%;
  height: 40%;
  width: 2%;
  z-index: 10;
  cursor: pointer;
}

.hand.second {
  left: 49.5%;
  top: 10%;
  height: 40%;
  width: 1%;
}

.editMode.root {
  outline: 2px solid skyblue;
}
Enter fullscreen mode Exit fullscreen mode
// src/CompositeClock/AnalogueController.ts
import type React from 'react';
import { AnalogueModel } from './AnalogueModel';

const TWO_PI = 2 * Math.PI;

export class AnalogueController {
  private model: AnalogueModel;

  constructor(model: AnalogueModel) {
    this.model = model;
  }

  onMinuteHandMouseDown = (e: React.MouseEvent<HTMLDivElement>): void => {
    e.preventDefault();
    this.model.enterEditMode();
  };

  onMouseLeave = (): void => {
    this.model.exitEditMode();
  };

  onMouseUp = (): void => {
    this.model.exitEditMode();
  };

  onKeyDown = (e: KeyboardEvent): void => {
    if (this.model.getState().isEditMode && e.key === 'Escape') {
      this.model.exitEditMode(false);
    }
  };

  onMouseMove = (e: React.MouseEvent<HTMLDivElement>): void => {
    const { isEditMode } = this.model.getState();
    if (!isEditMode) return;

    const boundingBox = e.currentTarget.getBoundingClientRect();
    const originX = boundingBox.x + boundingBox.width / 2;
    const originY = boundingBox.y + boundingBox.height / 2;

    const pointX = e.clientX - originX;
    const pointY = originY - e.clientY;

    this.model.changeEditModeMinuteAngle(this.calcEditModeMinuteAngle(pointX, pointY));
  };

  calcEditModeMinuteAngle(pointX: number, pointY: number): number {
    const pointLen = Math.sqrt(Math.pow(pointX, 2) + Math.pow(pointY, 2));

    const normalizedX = pointX / pointLen;
    const normalizedY = pointY / pointLen;

    const { editModeAngles } = this.model.getState();
    const oldX = Math.sin(editModeAngles.minute);
    const oldY = Math.cos(editModeAngles.minute);

    const rawMinuteAngle = Math.acos(normalizedY);

    const minuteAngle =
      normalizedY > 0 && oldY > 0
        ? normalizedX >= 0
          ? oldX < 0
            ? rawMinuteAngle + TWO_PI
            : rawMinuteAngle
          : oldX >= 0
          ? -rawMinuteAngle
          : -rawMinuteAngle + TWO_PI
        : normalizedX >= 0
        ? rawMinuteAngle
        : -rawMinuteAngle + TWO_PI;

    return minuteAngle;
  }
}
Enter fullscreen mode Exit fullscreen mode
// src/CompositeClock/DigitalView.tsx
import { FC, useEffect, useRef, useState } from 'react';
import { DigitalController } from './DigitalController';
import { DigitalModel } from './DigitalModel';
import styles from './DigitalView.module.css';

interface Props {
  className?: string;
  model: DigitalModel;
  controller: DigitalController;
}

export const DigitalView: FC<Props> = ({ className, model, controller }) => {
  const [{ displayText, isEditMode, editModeText }, setState] = useState(model.getState());

  const refEditor = useRef<HTMLInputElement | null>(null);

  useEffect(() => {
    [
      DigitalModel.EVENTS.DISPLAY_TEXT_CHANGED,
      DigitalModel.EVENTS.IS_EDIT_MODE_CHANGED,
      DigitalModel.EVENTS.EDIT_MODE_TEXT_CHANGED,
    ].forEach((event) => {
      model.addListener(event, () => setState(model.getState()));
    });
  }, [model]);

  useEffect(() => {
    if (isEditMode && refEditor.current) {
      refEditor.current.select();
    }
  }, [isEditMode]);

  return (
    <div className={`${className ?? ''} ${styles.root} ${isEditMode ? styles.editMode : ''}`}>
      {isEditMode ? (
        <>
          <input
            className={styles.editor}
            type="text"
            ref={refEditor}
            value={editModeText}
            onBlur={controller.onEditorBlur}
            onChange={controller.onEditorChange}
            onKeyDown={controller.onEditorKeyDown}
          />
          {!model.isEditModeTextValid() && (
            <div className={styles.invalidHint}>
              The input time doesn't match the expected format which is '{DigitalModel.FORMAT}'.
            </div>
          )}
        </>
      ) : (
        <div onClick={controller.onDisplayClick}>{displayText}</div>
      )}
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode
/* src/CompositeClock/DigitalView.module.css */
.root {
  border: 1px solid black;
  width: 200px;
  line-height: 30px;
  text-align: center;
}

.editor {
  width: 100%;
  text-align: center;
  font-size: inherit;
  padding: 0;
  border: none;
  outline: none;
}

.invalidHint {
  line-height: 1.2;
}

.editMode.root {
  outline: 2px solid skyblue;
}
Enter fullscreen mode Exit fullscreen mode
// src/CompositeClock/DigitalController.ts
import type React from 'react';
import { DigitalModel } from './DigitalModel';

export class DigitalController {
  private model: DigitalModel;

  constructor(model: DigitalModel) {
    this.model = model;
  }

  onDisplayClick = (): void => {
    this.model.enterEditMode();
  };

  onEditorBlur = (): void => {
    this.model.exitEditMode(false);
  };

  onEditorChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
    this.model.changeEditModeText(e.target.value);
  };

  onEditorKeyDown = (e: React.KeyboardEvent): void => {
    if (e.key === 'Enter') {
      this.model.exitEditMode();
    }
  };
}
Enter fullscreen mode Exit fullscreen mode
// src/CompositeClock/CompositeView.tsx
import { FC, useEffect, useMemo } from 'react';
import { AnalogueController } from './AnalogueController';
import { AnalogueModel } from './AnalogueModel';
import { AnalogueView } from './AnalogueView';
import { CompositeController } from './CompositeController';
import styles from './CompositeView.module.css';
import { DigitalController } from './DigitalController';
import { DigitalModel } from './DigitalModel';
import { DigitalView } from './DigitalView';
import { TimeModel } from './TimeModel';

export const CompositeView: FC = () => {
  const timeModel = useMemo(() => new TimeModel(), []);
  const analogueModel = useMemo(() => new AnalogueModel(timeModel), [timeModel]);
  const analogueController = useMemo(() => new AnalogueController(analogueModel), [analogueModel]);
  const digitalModel = useMemo(() => new DigitalModel(timeModel), [timeModel]);
  const digitalController = useMemo(() => new DigitalController(digitalModel), [digitalModel]);
  const compositeController = useMemo(
    () => new CompositeController({ analogueModel, digitalModel, timeModel }),
    [analogueModel, digitalModel, timeModel]
  );

  useEffect(() => {
    const tickHandler = setInterval(() => {
      compositeController.tick();
    }, 100);
    return () => clearInterval(tickHandler);
  }, [compositeController]);

  return (
    <div className={styles.root}>
      <AnalogueView model={analogueModel} controller={analogueController} />
      <DigitalView model={digitalModel} controller={digitalController} />
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode
/* src/CompositeClock/CompositeView.module.css */
.root {
  margin: 16px 8px;
  font-size: 16px;
}
Enter fullscreen mode Exit fullscreen mode
// src/CompositeClock/CompositeController.ts
import { AnalogueModel } from './AnalogueModel';
import { DigitalModel } from './DigitalModel';
import { TimeModel } from './TimeModel';

export class CompositeController {
  private analogueModel: AnalogueModel;
  private digitalModel: DigitalModel;
  private timeModel: TimeModel;
  private timestampCorrection: number;

  constructor(models: {
    analogueModel: AnalogueModel;
    digitalModel: DigitalModel;
    timeModel: TimeModel;
  }) {
    this.analogueModel = models.analogueModel;
    this.digitalModel = models.digitalModel;
    this.timeModel = models.timeModel;
    this.timestampCorrection = this.calcTimestampCorrection();

    this.analogueModel.addListener(AnalogueModel.EVENTS.IS_EDIT_MODE_CHANGED, () => {
      if (!this.analogueModel.getState().isEditMode) {
        this.timestampCorrection = this.calcTimestampCorrection();
      }
    });

    this.digitalModel.addListener(DigitalModel.EVENTS.IS_EDIT_MODE_CHANGED, () => {
      if (!this.digitalModel.getState().isEditMode) {
        this.timestampCorrection = this.calcTimestampCorrection();
      }
    });
  }

  calcTimestampCorrection(): number {
    return this.timeModel.getState().timestamp - Date.now();
  }

  tick(): void {
    this.timeModel.changeTimestamp(Date.now() + this.timestampCorrection);
  }
}
Enter fullscreen mode Exit fullscreen mode

Afterwards, CompositeView is exported and used in App.tsx:

// src/CompositeClock/index.ts
export { CompositeView as CompositeClock } from './CompositeView';
Enter fullscreen mode Exit fullscreen mode
// src/App.tsx
import { FC } from 'react';
+import { CompositeClock } from './CompositeClock';

const App: FC = () => {
-  return null;
+  return <CompositeClock />;
};

export default App;
Enter fullscreen mode Exit fullscreen mode

Then, the example module built with MVC pattern is complete. It can be previewed with the command npm start and its codebase is hosted at review-of-state-management-in-react/01-getting-started-with-an-mvc-example.

Review of state management with MVC pattern

In MVC pattern, state-changing logics are defined by model methods. To understand what states a model method changes, what model attributes the method changes needs to be tracked out by looking into function bodies of the method and all the direct or indirect subscribers of the state-changing event emitted by the method. Because more model methods can be invoked in or as subscribers of a state-changing event, more state-changing events can be emitted, then more and more model methods can be invoked. And, as the app scales up, fully tracking state-changing events can become very difficult. The result is, invoking any model method may lead to a tangled weave of state-changing logics, which makes states changing in MVC pattern unpredictable. It can be perceived by checking how TimeModel.ts, AnalogueModel.ts and DigitalModel.ts work with each other and their views. Unpredictable states changing due to difficulties in fully tracking state-changing events makes up the biggest con of MVC pattern.

But, meanwhile, a major pro of MVC pattern is, all the state-managing logics related to one state can be clearly defined in one model, which makes the app domain clearly split.

As a sum-up, doing state management with MVC pattern makes unpredictable states changing (but with limited benefits in other aspects).

What's next

By far, the baseline example module has been built with MVC pattern. Meanwhile, MVC pattern is reviewed based on it. Then, in the next article, continuing to answer the question #1, I would look into reducer-like solutions of state management in React - Redux and its family.

Top comments (0)