DEV Community

Cover image for Review of state management in React: reducer-like solutions - Redux and its family
C
C

Posted on • Edited on

Review of state management in React: reducer-like solutions - Redux and its family

Continuing to answer the question #1 in the last article, How good are today's widely-accepted libraries of state management in React?, I would look into reducer-like solutions of state management in React by this article, because they have become so widely-accepted that the most well-known one, Redux, is used in 1/3 of all React projects and even React itself provides a built-in hook useReducer.

As mentioned previously, with the example of the composite clock built with MVC pattern from the last article as a baseline, for each reducer-like solution, I would rebuild the same example module with it and review how good it is in comparison with the baseline.

Table of contents

Reduce function

Reduce function was formally introduced as a general higher-order function in functional programming. It takes a binary operator and an initial result to produce a function that can process a list of values to return a final result. The binary operator is able to process a result and a value at a time to return a new result, so values in a list can be processed in sequence to return the final result from the initial result. And, this operator is called reducer, which can be depicted as follows:

type Reducer<TResult, TValue> = (result: TResult, value: TValue) => TResult;
Enter fullscreen mode Exit fullscreen mode

By reducer-like solutions of state management, I mean those libraries that do states changing in state management by using reducers or some similar ways. Reducers of them treat states as results and action payloads of user interactions as values, so action payloads can be processed in sequence to return the next state from the previous state:

type Reducer<TState, TAction> = (state: TState, action: TAction) => TState;
Enter fullscreen mode Exit fullscreen mode

In the perspective of the data flow, view components handle user interactions to dispatch actions to reducers and get the states back to render themselves in a timely manner. By the way, an action can also be dispatched anywhere except reducers.

Data flow of reducer-like solutions

Among reducer-like solutions, Redux and its family are the most widely accepted. For a comprehensive understanding, I would look into every important member in this family and my focus includes Redux itself, its child Redux Toolkit(RTK), its parent Flux and its nephew useReducer.

Recalling the example module

Before that, let me recall the requirement of the example module a bit in case a reader might not have time to fully read the last article. If the last article has been read, this section can be skipped:

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 Redux

Now, I would look into Redux first. Again, create-react-app is used to initialize the React app. The option --template typescript is used to enable TypeScript:

$ npx create-react-app 02-reducer-like-solutions-redux-and-its-family/77ed362 --template typescript
# ...
$ cd 02-reducer-like-solutions-redux-and-its-family/77ed362
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:

// 77ed362/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

Then, to use Redux, redux and react-redux are installed:

$ npm i redux react-redux
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 reducers with their actions, TimeReducer.ts with TimeActions.ts, AnalogueReducer.ts with AnalogueActions.ts and DigitalReducer.ts with DigitalActions.ts. For data deriving in Redux, it's achieved by using selectors which are basically getters on states and usually placed together with reducers.

And for view components, there are AnalogueView.ts for the analogue clock, DigitalView.ts for the digital clock, and CompositeView.ts as a glue. Besides, a Redux setup of a root store and related helpers is needed.

Relation of parts in the composite clock built with Redux

In terms of coding, all reducers are dependent on their actions for using the action types, some actions are dependent on the Redux setup for accessing the root store, the Redux setup is dependent on reducers for building the root store, which constitutes circular dependency in some cases. To get this coded, I would start from the Redux setup, then evolve it along with the rest parts step by step.

The Redux setup is initially coded as follows:

// 77ed362/src/reduxStore.ts
import { AnyAction, combineReducers, createStore, Store } from 'redux';

export type AppAction = AnyAction;

export interface AppState {}

export type AppStore = Store<AppState, AppAction>;

export type AppDispatch = AppStore['dispatch'];

export function createAppStore(initialState: Partial<AppState> = {}): AppStore {
  const store = createStore(combineReducers({}), initialState);
  return store;
}
Enter fullscreen mode Exit fullscreen mode
// 77ed362/src/reduxHooks.ts
import { TypedUseSelectorHook, useDispatch, useSelector, useStore } from 'react-redux';
import type { AppAction, AppDispatch, AppState } from './reduxStore';

export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<AppState> = useSelector;
export const useAppStore = () => useStore<AppState, AppAction>();
Enter fullscreen mode Exit fullscreen mode
// 77ed362/src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import App from './App';
import './index.css';
import { createAppStore } from './reduxStore';
import reportWebVitals from './reportWebVitals';

const appStore = createAppStore();

const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(
  <React.StrictMode>
    <Provider store={appStore}>
      <App />
    </Provider>
  </React.StrictMode>
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
Enter fullscreen mode Exit fullscreen mode

The reducer with its actions for the state of time and the integration with the Redux setup are coded as follows:

// 77ed362/src/CompositeClock/TimeActions.ts
const NS = 'COMPOSITE_CLOCK-TIME';

export const ActionTypes = {
  CHANGE_TIMESTAMP: `${NS}-CHANGE_TIMESTAMP`,
} as const;

export type TimeAction = { type: typeof ActionTypes['CHANGE_TIMESTAMP']; timestamp: number };

export function changeTimestamp(timestamp: number): TimeAction {
  return {
    type: ActionTypes.CHANGE_TIMESTAMP,
    timestamp,
  };
}
Enter fullscreen mode Exit fullscreen mode
// 77ed362/src/CompositeClock/TimeReducer.ts
import { ActionTypes, TimeAction } from './TimeActions';

export interface TimeState {
  timestamp: number;
}

export function timeReducer(
  state: TimeState = {
    timestamp: 0,
  },
  action: TimeAction
): TimeState {
  switch (action.type) {
    case ActionTypes.CHANGE_TIMESTAMP:
      return { ...state, timestamp: action.timestamp };
  }
  return state;
}
Enter fullscreen mode Exit fullscreen mode
// 77ed362/src/CompositeClock/index.ts
export type { TimeAction } from './TimeActions';
export * from './TimeReducer';
Enter fullscreen mode Exit fullscreen mode
// 77ed362/src/reduxStore.ts
-import { AnyAction, combineReducers, createStore, Store } from 'redux';
+import { combineReducers, createStore, Store } from 'redux';
+import { TimeAction, timeReducer, TimeState } from './CompositeClock';

-export type AppAction = AnyAction;
+export type AppAction = TimeAction;

-export interface AppState {}
+export interface AppState {
+  timeOfClock: TimeState;
+}

...

export function createAppStore(initialState: Partial<AppState> = {}): AppStore {
-  const store = createStore(combineReducers({}), initialState);
+  const store = createStore(combineReducers({
+    timeOfClock: timeReducer,
+  }), initialState);
  return store;
}
Enter fullscreen mode Exit fullscreen mode
// 77ed362/src/index.tsx
...

-const appStore = createAppStore();
+const appStore = createAppStore({
+  timeOfClock: {
+    timestamp: Date.now(),
+  },
+});

...
Enter fullscreen mode Exit fullscreen mode

The reducer with its actions for the state of the analogue clock and the integration with the Redux setup are coded as follows. Because AnalogueReducer.ts is dependent on AnalogueActions.ts, AnalogueActions.ts is dependent on reduxStore.ts, reduxStore.ts is dependent on AnalogueReducer.ts and they constitue circle dependency, I would code AnalogueActions.ts and AnalogueReducer.ts partially at first, adjust reduxStore.ts next, code AnalogueActions.ts and AnalogueReducer.ts completely in the end:

// 77ed362/src/CompositeClock/AnalogueActions.ts
const NS = 'COMPOSITE_CLOCK-ANALOGUE';

export const ActionTypes = {
  ENTER_EDIT_MODE: `${NS}-ENTER_EDIT_MODE`,
  EXIT_EDIT_MODE: `${NS}-EXIT_EDIT_MODE`,
  CHANGE_EDIT_MODE_MINUTE_ANGLE: `${NS}-CHANGE_EDIT_MODE_MINUTE_ANGLE`,
} as const;

export type AnalogueAction =
  | { type: typeof ActionTypes['ENTER_EDIT_MODE'] }
  | { type: typeof ActionTypes['EXIT_EDIT_MODE'] }
  | { type: typeof ActionTypes['CHANGE_EDIT_MODE_MINUTE_ANGLE'] };
Enter fullscreen mode Exit fullscreen mode
// 77ed362/src/CompositeClock/AnalogueReducer.ts
import { ActionTypes, AnalogueAction } from './AnalogueActions';
import type { TimeState } from './TimeReducer';

const TWO_PI = 2 * Math.PI;

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

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

export function analogueReducer(
  state: AnalogueState = {
    isEditMode: false,
    editModeAngles: { hour: 0, minute: 0, second: 0 },
  },
  action: AnalogueAction
): AnalogueState {
  switch (action.type) {
    case ActionTypes.ENTER_EDIT_MODE:
    case ActionTypes.EXIT_EDIT_MODE:
    case ActionTypes.CHANGE_EDIT_MODE_MINUTE_ANGLE:
  }
  return state;
}

export function getDisplayAngles(timeState: TimeState): AnalogueAngles {
  const d = new Date(timeState.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,
  };
}
Enter fullscreen mode Exit fullscreen mode
// 77ed362/src/CompositeClock/index.ts
+export type { AnalogueAction } from './AnalogueActions';
+export * from './AnalogueReducer';
export type { TimeAction } from './TimeActions';
export * from './TimeReducer';
Enter fullscreen mode Exit fullscreen mode
// 77ed362/src/reduxStore.ts
import { combineReducers, createStore, Store } from 'redux';
-import { TimeAction, timeReducer, TimeState } from './CompositeClock';
+import {
+  AnalogueAction,
+  analogueReducer,
+  AnalogueState,
+  TimeAction,
+  timeReducer,
+  TimeState,
+} from './CompositeClock';

-export type AppAction = TimeAction;
+export type AppAction = TimeAction | AnalogueAction;

export interface AppState {
  timeOfClock: TimeState;
+  analogueClock: AnalogueState;
}

...

export function createAppStore(initialState: Partial<AppState> = {}): AppStore {
  const store = createStore(
    combineReducers({
      timeOfClock: timeReducer,
+      analogueClock: analogueReducer,
    }),
    initialState
  );
  return store;
}
Enter fullscreen mode Exit fullscreen mode
// 77ed362/src/CompositeClock/AnalogueActions.ts
+import type { AppStore } from '../reduxStore';
+import { AnalogueAngles, getDisplayAngles } from './AnalogueReducer';
+import { changeTimestamp } from './TimeActions';
+
+const TWO_PI = 2 * Math.PI;
+
const NS = 'COMPOSITE_CLOCK-ANALOGUE';

export const ActionTypes = {
  ENTER_EDIT_MODE: `${NS}-ENTER_EDIT_MODE`,
  EXIT_EDIT_MODE: `${NS}-EXIT_EDIT_MODE`,
  CHANGE_EDIT_MODE_MINUTE_ANGLE: `${NS}-CHANGE_EDIT_MODE_MINUTE_ANGLE`,
} as const;

export type AnalogueAction =
-  | { type: typeof ActionTypes['ENTER_EDIT_MODE'] }
+  | { type: typeof ActionTypes['ENTER_EDIT_MODE']; editModeAngles: AnalogueAngles }
  | { type: typeof ActionTypes['EXIT_EDIT_MODE'] }
-  | { type: typeof ActionTypes['CHANGE_EDIT_MODE_MINUTE_ANGLE'] };
+  | { type: typeof ActionTypes['CHANGE_EDIT_MODE_MINUTE_ANGLE']; minuteAngle: number };
+
+export function dispatchEnterEditMode(store: AppStore): void {
+  const { timeOfClock, analogueClock } = store.getState();
+  if (analogueClock.isEditMode) return;
+  const editModeAngles = getDisplayAngles(timeOfClock);
+  store.dispatch({
+    type: ActionTypes.ENTER_EDIT_MODE,
+    editModeAngles,
+  });
+}
+
+export function dispatchExitEditMode(store: AppStore, submit: boolean = true): void {
+  const { timeOfClock, analogueClock } = store.getState();
+  if (!analogueClock.isEditMode) return;
+  if (submit) {
+    const d = new Date(timeOfClock.timestamp);
+    d.setHours(
+      Math.floor((analogueClock.editModeAngles.hour / TWO_PI) * 12) +
+        12 * Math.floor(d.getHours() / 12)
+    );
+    d.setMinutes((analogueClock.editModeAngles.minute / TWO_PI) * 60);
+    d.setSeconds((analogueClock.editModeAngles.second / TWO_PI) * 60);
+    store.dispatch(changeTimestamp(d.getTime()));
+  }
+  store.dispatch({ type: ActionTypes.EXIT_EDIT_MODE });
+}
+
+export function changeEditModeMinuteAngle(minuteAngle: number): AnalogueAction {
+  return { type: ActionTypes.CHANGE_EDIT_MODE_MINUTE_ANGLE, minuteAngle };
+}
Enter fullscreen mode Exit fullscreen mode
// 77ed362/src/CompositeClock/AnalogueReducer.ts
...

export function analogueReducer(
  state: AnalogueState = {
    isEditMode: false,
    editModeAngles: { hour: 0, minute: 0, second: 0 },
  },
  action: AnalogueAction
): AnalogueState {
  switch (action.type) {
    case ActionTypes.ENTER_EDIT_MODE:
+      return { ...state, isEditMode: true, editModeAngles: action.editModeAngles };
    case ActionTypes.EXIT_EDIT_MODE:
+      return { ...state, isEditMode: false };
    case ActionTypes.CHANGE_EDIT_MODE_MINUTE_ANGLE:
+      return {
+        ...state,
+        editModeAngles: {
+          ...state.editModeAngles,
+          minute: (action.minuteAngle + TWO_PI) % TWO_PI,
+          hour:
+            (Math.floor((state.editModeAngles.hour / TWO_PI) * 12) + action.minuteAngle / TWO_PI) *
+            (TWO_PI / 12),
+        },
+      };
  }
  return state;
}

...
Enter fullscreen mode Exit fullscreen mode

The reducer with its actions for the state of the digital clock and the integration with the Redux setup are coded as follows. Again, because DigitalReducer.ts is dependent on DigitalActions.ts, DigitalActions.ts is dependent on reduxStore.ts, reduxStore.ts is dependent on DigitalReducer.ts and they constitue circular dependency, I would code DigitalActions.ts and DigitalReducer.ts partially at first, adjust reduxStore.ts next, code DigitalActions.ts and DigitalReducer.ts completely in the end:

// 77ed362/src/CompositeClock/DigitalActions.ts
const NS = 'COMPOSITE_CLOCK-DIGITAL';

export const ActionTypes = {
  ENTER_EDIT_MODE: `${NS}-ENTER_EDIT_MODE`,
  EXIT_EDIT_MODE: `${NS}-EXIT_EDIT_MODE`,
  CHANGE_EDIT_MODE_TEXT: `${NS}-CHANGE_EDIT_MODE_TEXT`,
} as const;

export type DigitalAction =
  | { type: typeof ActionTypes['ENTER_EDIT_MODE'] }
  | { type: typeof ActionTypes['EXIT_EDIT_MODE'] }
  | { type: typeof ActionTypes['CHANGE_EDIT_MODE_TEXT'] };
Enter fullscreen mode Exit fullscreen mode
// 77ed362/src/CompositeClock/DigitalReducer.ts
import { format, isMatch } from 'date-fns';
import { ActionTypes, DigitalAction } from './DigitalActions';
import type { TimeState } from './TimeReducer';

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

export const DIGITAL_TEXT_FORMAT = 'HH:mm:ss';

export function digitalReducer(
  state: DigitalState = {
    isEditMode: false,
    editModeText: '',
  },
  action: DigitalAction
): DigitalState {
  switch (action.type) {
    case ActionTypes.ENTER_EDIT_MODE:
    case ActionTypes.EXIT_EDIT_MODE:
    case ActionTypes.CHANGE_EDIT_MODE_TEXT:
  }
  return state;
}

export function isEditModeTextValid(state: DigitalState): boolean {
  return isMatch(state.editModeText, DIGITAL_TEXT_FORMAT);
}

export function getDisplayText(timeState: TimeState): string {
  return format(timeState.timestamp, DIGITAL_TEXT_FORMAT);
}
Enter fullscreen mode Exit fullscreen mode
// 77ed362/src/CompositeClock/index.ts
export type { AnalogueAction } from './AnalogueActions';
export * from './AnalogueReducer';
+export type { DigitalAction } from './DigitalActions';
+export * from './DigitalReducer';
export type { TimeAction } from './TimeActions';
export * from './TimeReducer';
Enter fullscreen mode Exit fullscreen mode
// 77ed362/src/reduxStore.ts
import { combineReducers, createStore, Store } from 'redux';
import {
  AnalogueAction,
  analogueReducer,
  AnalogueState,
+  DigitalAction,
+  digitalReducer,
+  DigitalState,
  TimeAction,
  timeReducer,
  TimeState,
} from './CompositeClock';

-export type AppAction = TimeAction | AnalogueAction;
+export type AppAction = TimeAction | AnalogueAction | DigitalAction;

export interface AppState {
  timeOfClock: TimeState;
  analogueClock: AnalogueState;
+  digitalClock: DigitalState;
}

export type AppStore = Store<AppState, AppAction>;

export type AppDispatch = AppStore['dispatch'];

export function createAppStore(initialState: Partial<AppState> = {}): AppStore {
  const store = createStore(
    combineReducers({
      timeOfClock: timeReducer,
      analogueClock: analogueReducer,
+      digitalClock: digitalReducer,
    }),
    initialState
  );
  return store;
}
Enter fullscreen mode Exit fullscreen mode
// 77ed362/src/CompositeClock/DigitalActions.ts
+import { parse } from 'date-fns';
+import type { AppStore } from '../reduxStore';
+import { DIGITAL_TEXT_FORMAT, getDisplayText, isEditModeTextValid } from './DigitalReducer';
+import { changeTimestamp } from './TimeActions';
+
const NS = 'COMPOSITE_CLOCK-DIGITAL';

export const ActionTypes = {
  ENTER_EDIT_MODE: `${NS}-ENTER_EDIT_MODE`,
  EXIT_EDIT_MODE: `${NS}-EXIT_EDIT_MODE`,
  CHANGE_EDIT_MODE_TEXT: `${NS}-CHANGE_EDIT_MODE_TEXT`,
} as const;

export type DigitalAction =
-  | { type: typeof ActionTypes['ENTER_EDIT_MODE'] }
+  | { type: typeof ActionTypes['ENTER_EDIT_MODE']; editModeText: string }
  | { type: typeof ActionTypes['EXIT_EDIT_MODE'] }
-  | { type: typeof ActionTypes['CHANGE_EDIT_MODE_TEXT'] };
+  | { type: typeof ActionTypes['CHANGE_EDIT_MODE_TEXT']; editModeText: string };
+
+export function dispatchEnterEditMode(store: AppStore): void {
+  const { timeOfClock, digitalClock } = store.getState();
+  if (digitalClock.isEditMode) return;
+  const editModeText = getDisplayText(timeOfClock);
+  store.dispatch({
+    type: ActionTypes.ENTER_EDIT_MODE,
+    editModeText,
+  });
+}
+
+export function dispatchExitEditMode(store: AppStore, submit: boolean = true): void {
+  const { timeOfClock, digitalClock } = store.getState();
+  if (!digitalClock.isEditMode) return;
+  if (submit && isEditModeTextValid(digitalClock)) {
+    store.dispatch(
+      changeTimestamp(
+        parse(digitalClock.editModeText, DIGITAL_TEXT_FORMAT, timeOfClock.timestamp).getTime()
+      )
+    );
+  }
+  store.dispatch({ type: ActionTypes.EXIT_EDIT_MODE });
+}
+
+export function changeEditModeText(editModeText: string): DigitalAction {
+  return { type: ActionTypes.CHANGE_EDIT_MODE_TEXT, editModeText };
+}
Enter fullscreen mode Exit fullscreen mode
// 77ed362/src/CompositeClock/DigitalReducer.ts
...

export function digitalReducer(
  state: DigitalState = {
    isEditMode: false,
    editModeText: '',
  },
  action: DigitalAction
): DigitalState {
  switch (action.type) {
    case ActionTypes.ENTER_EDIT_MODE:
+      return { ...state, isEditMode: true, editModeText: action.editModeText };
    case ActionTypes.EXIT_EDIT_MODE:
+      return { ...state, isEditMode: false };
    case ActionTypes.CHANGE_EDIT_MODE_TEXT:
+      return { ...state, editModeText: action.editModeText };
  }
  return state;
}

...
Enter fullscreen mode Exit fullscreen mode

After that, the view components are coded as follows:

// 77ed362/src/CompositeClock/AnalogueView.tsx
import { FC, useCallback, useEffect } from 'react';
import { useAppDispatch, useAppSelector, useAppStore } from '../reduxHooks';
import {
  changeEditModeMinuteAngle,
  dispatchEnterEditMode,
  dispatchExitEditMode,
} from './AnalogueActions';
import { getDisplayAngles } from './AnalogueReducer';
import styles from './AnalogueView.module.css';

const TWO_PI = 2 * Math.PI;

interface Props {
  className?: string;
}

export const AnalogueView: FC<Props> = ({ className }) => {
  const store = useAppStore();
  const dispatch = useAppDispatch();
  const state = useAppSelector((appState) => appState.analogueClock);
  const { isEditMode, editModeAngles } = state;
  const displayAngles = useAppSelector((appState) => getDisplayAngles(appState.timeOfClock));

  const angles = isEditMode ? editModeAngles : displayAngles;

  const calcEditModeMinuteAngle = useCallback(
    (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 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;
    },
    [editModeAngles]
  );

  const onMinuteHandMouseDown = useCallback(
    (e: React.MouseEvent<HTMLDivElement>) => {
      e.preventDefault();
      dispatchEnterEditMode(store);
    },
    [store]
  );

  const onMouseLeave = useCallback(() => dispatchExitEditMode(store), [store]);

  const onMouseUp = useCallback(() => dispatchExitEditMode(store), [store]);

  const onKeyDown = useCallback(
    (e: KeyboardEvent): void => {
      if (isEditMode && e.key === 'Escape') {
        dispatchExitEditMode(store, false);
      }
    },
    [isEditMode, store]
  );

  const onMouseMove = useCallback(
    (e: React.MouseEvent<HTMLDivElement>): void => {
      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;

      dispatch(changeEditModeMinuteAngle(calcEditModeMinuteAngle(pointX, pointY)));
    },
    [calcEditModeMinuteAngle, dispatch, isEditMode]
  );

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

  return (
    <div
      className={`${className ?? ''} ${styles.root} ${isEditMode ? styles.editMode : ''}`}
      onMouseLeave={onMouseLeave}
      onMouseUp={onMouseUp}
      onMouseMove={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={onMinuteHandMouseDown}
      />
      <div
        className={`${styles.hand} ${styles.second}`}
        style={{ transform: `rotateZ(${angles.second}rad)` }}
      />
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode
/* 77ed362/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
// 77ed362/src/CompositeClock/DigitalView.tsx
import { FC, useCallback, useEffect, useRef } from 'react';
import { useAppDispatch, useAppSelector, useAppStore } from '../reduxHooks';
import { changeEditModeText, dispatchEnterEditMode, dispatchExitEditMode } from './DigitalActions';
import { DIGITAL_TEXT_FORMAT, getDisplayText, isEditModeTextValid } from './DigitalReducer';
import styles from './DigitalView.module.css';

interface Props {
  className?: string;
}

export const DigitalView: FC<Props> = ({ className }) => {
  const store = useAppStore();
  const dispatch = useAppDispatch();
  const state = useAppSelector((appState) => appState.digitalClock);
  const { isEditMode, editModeText } = state;
  const displayText = useAppSelector((appState) => getDisplayText(appState.timeOfClock));

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

  const onDisplayClick = useCallback(() => dispatchEnterEditMode(store), [store]);

  const onEditorBlur = useCallback(() => dispatchExitEditMode(store, false), [store]);

  const onEditorChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => dispatch(changeEditModeText(e.target.value)),
    [dispatch]
  );

  const onEditorKeyDown = useCallback(
    (e: React.KeyboardEvent) => {
      if (e.key === 'Enter') {
        dispatchExitEditMode(store);
      }
    },
    [store]
  );

  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={onEditorBlur}
            onChange={onEditorChange}
            onKeyDown={onEditorKeyDown}
          />
          {!isEditModeTextValid(state) && (
            <div className={styles.invalidHint}>
              The input time doesn't match the expected format which is '{DIGITAL_TEXT_FORMAT}'.
            </div>
          )}
        </>
      ) : (
        <div onClick={onDisplayClick}>{displayText}</div>
      )}
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode
/* 77ed362/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
// 77ed362/src/CompositeClock/CompositeView.tsx
import { FC, useCallback, useEffect, useRef } from 'react';
import { useAppDispatch, useAppSelector, useAppStore } from '../reduxHooks';
import { AnalogueView } from './AnalogueView';
import styles from './CompositeView.module.css';
import { DigitalView } from './DigitalView';
import { changeTimestamp } from './TimeActions';

export const CompositeView: FC = () => {
  const dispatch = useAppDispatch();
  const store = useAppStore();
  const isEditModeInAnalogueClock = useAppSelector((appState) => appState.analogueClock.isEditMode);
  const isEditModeInDigitalClock = useAppSelector((appState) => appState.digitalClock.isEditMode);

  const calcTimestampCorrection = useCallback(
    () => store.getState().timeOfClock.timestamp - Date.now(),
    [store]
  );

  const refTimeCorrection = useRef<number>(calcTimestampCorrection());

  useEffect(() => {
    if (!isEditModeInAnalogueClock || !isEditModeInDigitalClock) {
      refTimeCorrection.current = calcTimestampCorrection();
    }
  }, [calcTimestampCorrection, isEditModeInAnalogueClock, isEditModeInDigitalClock]);

  useEffect(() => {
    const tickHandler = setInterval(
      () => dispatch(changeTimestamp(Date.now() + refTimeCorrection.current)),
      100
    );
    return () => clearInterval(tickHandler);
  }, [dispatch]);

  return (
    <div className={styles.root}>
      <AnalogueView />
      <DigitalView />
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode
/* 77ed362/src/CompositeClock/CompositeView.module.css */
.root {
  margin: 16px 8px;
  font-size: 16px;
}
Enter fullscreen mode Exit fullscreen mode
// 77ed362/src/CompositeClock/index.ts
export type { AnalogueAction } from './AnalogueActions';
export * from './AnalogueReducer';
+export { CompositeView as CompositeClock } from './CompositeView';
export type { DigitalAction } from './DigitalActions';
export * from './DigitalReducer';
export type { TimeAction } from './TimeActions';
export * from './TimeReducer';
Enter fullscreen mode Exit fullscreen mode
// 77ed362/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 Redux is complete. It can be previewed with the command npm start and its codebase is hosted at review-of-state-management-in-react/02-reducer-like-solutions-redux-and-its-family/77ed362.

Review of state management with Redux

In Redux, one-state changing logics are defined by reducers with optional simple actions. Because simple actions are only to pass payloads on to reducers, they have little weight on states changing. Because each reducer is a pure function that processes one state to return a new state of the one without any side effects, what one state a reducer changes can be clearly understood by only reading the reducer's function declaration, which makes one-state changing in Redux predictable without tracking logics in any function bodies. On the other hand, multi-state changing logics are defined by multi-state changing actions. To understand what states a multi-state changing action changes, what reducers the action involves needs to be tracked out by looking into function bodies of the action and other actions invoked by the action. But, unlike tracking state-changing events in MVC pattern, the tracking work in Redux actions is as easy as tracking plain constants and plain functions, which makes multi-state changing in Redux predictable at limited cost. It can be perceived by checking how TimeReducer.ts, AnalogueReducer.ts and DigitalReducer.ts and their actions work. Predictable one-state changing by only checking function declarations of reducers and predictable multi-state changing at limited cost of tracking involved reducers in function bodies of actions make up the brightest pro of Redux.

But, meanwhile, because building and maintaining a reducer always involve building and maintaining its actions and vise versa, a reducer and its actions are high-coupling, which brings difficulties in development, thus overall cost of development becomes high. Especially, when some actions of a reducer are dependent on the Redux setup for accessing the root store, the reducer, its actions and the Redux setup constitute circular dependency, so they need to be coded iteratively, thus the cost becomes even higher. High coupling between reducers and their actions leads to the biggest con of Redux.

Besides, a module built with Redux is actually not completely modularized by default, because when a module gets initialized for multiple instances, these instances are sharing exactly the same states in the app-wide Redux setup. To resolve this issue, a module-wide Redux setup needs to be introduced per module, which is costly in Redux. Incomplete modularization by default is another major con of Redux.

To sum up, doing state management with Redux achieves predictable states changing at limited cost of only tracking multi-state changing logics but at quite high overall cost of development.

Example module built with Redux Toolkit

Now that using Redux is costly, let me check out its next-generation solution, Redux Toolkit(RTK), whose goal is to decrease cost of development. As the view components in every example module built with a reducer-like solution would all be similarly coded and relate little to state management, I would not list them in the following sections but they can still be found in the hosted codebase.

Again, create-react-app is used to initialize the React app with the option --template typescript:

$ npx create-react-app 02-reducer-like-solutions-redux-and-its-family/d1b2fad --template typescript
# ...
$ cd 02-reducer-like-solutions-redux-and-its-family/d1b2fad
Enter fullscreen mode Exit fullscreen mode

Then, src/App.tsx is cleared for later use, unused files are removed, the package of time helpers is installed:

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

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

export default App;
Enter fullscreen mode Exit fullscreen mode
$ rm src/App.css src/App.test.tsx src/logo.svg
Enter fullscreen mode Exit fullscreen mode
$ npm i date-fns
Enter fullscreen mode Exit fullscreen mode

Then, to use RTK, @reduxjs/toolkit and react-redux are installed:

$ npm i @reduxjs/toolkit react-redux
Enter fullscreen mode Exit fullscreen mode

RTK is a set of helpers simplifying logics in Redux to different levels. I would use the helpers that simplify the logics most to decrease the cost, so createSlice is used to simplify reducers and actions, and configureStore is used for the Redux setup. By a slice, it's an easily definable combination of a reducer with its one-state changing actions. And for advanced actions that access the root store and/or dispatch other actions, they can be coded following the usage of thunks and placed together with slices.

In terms of coding, slices are self-contained, advanced actions are dependent on the Redux setup for accessing the root store, the Redux setup is dependent on slices for building the root store. So, I would start from slices and evolve the rest parts afterwards.

The slices are coded as follows. Thinking of the advanced actions to be coded later, some one-state changing actions whose function names starting with _ are prepared ahead:

// d1b2fad/src/CompositeClock/TimeSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

export interface TimeState {
  timestamp: number;
}

const initialState: TimeState = {
  timestamp: 0,
};

const timeSlice = createSlice({
  name: 'COMPOSITE_CLOCK-TIME',
  initialState,
  reducers: {
    changeTimestamp(state, action: PayloadAction<number>) {
      state.timestamp = action.payload;
    },
  },
});

export const { changeTimestamp } = timeSlice.actions;

export const timeReducer = timeSlice.reducer;
Enter fullscreen mode Exit fullscreen mode
// d1b2fad/src/CompositeClock/AnalogueSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

const TWO_PI = 2 * Math.PI;

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

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

const initialState: AnalogueState = {
  isEditMode: false,
  editModeAngles: { hour: 0, minute: 0, second: 0 },
};

const analogueSlice = createSlice({
  name: 'COMPOSITE_CLOCK-ANALOGUE',
  initialState,
  reducers: {
    _enterEditMode(state, action: PayloadAction<AnalogueAngles>) {
      state.isEditMode = true;
      state.editModeAngles = action.payload;
    },

    _exitEditMode(state) {
      state.isEditMode = false;
    },

    changeEditModeMinuteAngle(state, action: PayloadAction<number>) {
      const minuteAngle = action.payload;
      state.editModeAngles.minute = (minuteAngle + TWO_PI) % TWO_PI;
      state.editModeAngles.hour =
        (Math.floor((state.editModeAngles.hour / TWO_PI) * 12) + minuteAngle / TWO_PI) *
        (TWO_PI / 12);
    },
  },
});

const { _enterEditMode, _exitEditMode } = analogueSlice.actions;

export const { changeEditModeMinuteAngle } = analogueSlice.actions;

export const analogueReducer = analogueSlice.reducer;
Enter fullscreen mode Exit fullscreen mode
// d1b2fad/src/CompositeClock/DigitalSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

export const DIGITAL_TEXT_FORMAT = 'HH:mm:ss';

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

const initialState: DigitalState = {
  isEditMode: false,
  editModeText: '',
};

const digitalSlice = createSlice({
  name: 'COMPOSITE_CLOCK-DIGITAL',
  initialState,
  reducers: {
    _enterEditMode(state, action: PayloadAction<string>) {
      state.isEditMode = true;
      state.editModeText = action.payload;
    },

    _exitEditMode(state) {
      state.isEditMode = false;
    },

    changeEditModeText(state, action: PayloadAction<string>) {
      state.editModeText = action.payload;
    },
  },
});

const { _enterEditMode, _exitEditMode } = digitalSlice.actions;

export const { changeEditModeText } = digitalSlice.actions;

export const digitalReducer = digitalSlice.reducer;
Enter fullscreen mode Exit fullscreen mode
// d1b2fad/src/CompositeClock/index.ts
export { analogueReducer } from './AnalogueSlice';
export type { AnalogueState } from './AnalogueSlice';
export { digitalReducer } from './DigitalSlice';
export type { DigitalState } from './DigitalSlice';
export { timeReducer } from './TimeSlice';
export type { TimeState } from './TimeSlice';
Enter fullscreen mode Exit fullscreen mode

Then, the Redux setup is coded as follows:

// d1b2fad/src/reduxStore.ts
import { AnyAction, configureStore } from '@reduxjs/toolkit';
import {
  analogueReducer,
  AnalogueState,
  digitalReducer,
  DigitalState,
  timeReducer,
  TimeState,
} from './CompositeClock';

export interface AppState {
  timeOfClock: TimeState;
  analogueClock: AnalogueState;
  digitalClock: DigitalState;
}

export function createAppStore(initialState: Partial<AppState> = {}) {
  const store = configureStore<AppState, AnyAction>({
    reducer: {
      timeOfClock: timeReducer,
      analogueClock: analogueReducer,
      digitalClock: digitalReducer,
    },
    preloadedState: initialState,
  });
  return store;
}

export type AppStore = ReturnType<typeof createAppStore>;

export type AppDispatch = AppStore['dispatch'];

export type AppThunk = (dispatch: AppDispatch, getState: () => AppState) => void;
Enter fullscreen mode Exit fullscreen mode
// d1b2fad/src/reduxHooks.ts
import { AnyAction } from '@reduxjs/toolkit';
import { TypedUseSelectorHook, useDispatch, useSelector, useStore } from 'react-redux';
import type { AppDispatch, AppState } from './reduxStore';

export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<AppState> = useSelector;
export const useAppStore = () => useStore<AppState, AnyAction>();
Enter fullscreen mode Exit fullscreen mode
// d1b2fad/src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import App from './App';
import './index.css';
import { createAppStore } from './reduxStore';
import reportWebVitals from './reportWebVitals';

const appStore = createAppStore({
  timeOfClock: {
    timestamp: Date.now(),
  },
});

const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(
  <React.StrictMode>
    <Provider store={appStore}>
      <App />
    </Provider>
  </React.StrictMode>
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
Enter fullscreen mode Exit fullscreen mode

And, the advanced actions and the selectors are coded as follows. The advanced actions would use the previously prepared one-state changing actions whose function names start with _:

// d1b2fad/src/CompositeClock/AnalogueSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+import type { AppThunk } from '../reduxStore';
+import { changeTimestamp, TimeState } from './TimeSlice';

...

+export function enterEditMode(): AppThunk {
+  return (dispatch, getState) => {
+    const { timeOfClock, analogueClock } = getState();
+    if (analogueClock.isEditMode) return;
+    const editModeAngles = getDisplayAngles(timeOfClock);
+    dispatch(_enterEditMode(editModeAngles));
+  };
+}
+
+export function exitEditMode(submit: boolean = true): AppThunk {
+  return (dispatch, getState) => {
+    const { timeOfClock, analogueClock } = getState();
+    if (!analogueClock.isEditMode) return;
+    if (submit) {
+      const d = new Date(timeOfClock.timestamp);
+      d.setHours(
+        Math.floor((analogueClock.editModeAngles.hour / TWO_PI) * 12) +
+          12 * Math.floor(d.getHours() / 12)
+      );
+      d.setMinutes((analogueClock.editModeAngles.minute / TWO_PI) * 60);
+      d.setSeconds((analogueClock.editModeAngles.second / TWO_PI) * 60);
+      dispatch(changeTimestamp(d.getTime()));
+    }
+    dispatch(_exitEditMode());
+  };
+}
+
+export function getDisplayAngles(timeState: TimeState): AnalogueAngles {
+  const d = new Date(timeState.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,
+  };
+}
Enter fullscreen mode Exit fullscreen mode
// d1b2fad/src/CompositeClock/DigitalSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+import { format, isMatch, parse } from 'date-fns';
+import type { AppThunk } from '../reduxStore';
+import { changeTimestamp, TimeState } from './TimeSlice';

...

+export function enterEditMode(): AppThunk {
+  return (dispatch, getState) => {
+    const { timeOfClock, digitalClock } = getState();
+    if (digitalClock.isEditMode) return;
+    const editModeText = getDisplayText(timeOfClock);
+    dispatch(_enterEditMode(editModeText));
+  };
+}
+
+export function exitEditMode(submit: boolean = true): AppThunk {
+  return (dispatch, getState) => {
+    const { timeOfClock, digitalClock } = getState();
+    if (!digitalClock.isEditMode) return;
+    if (submit && isEditModeTextValid(digitalClock)) {
+      dispatch(
+        changeTimestamp(
+          parse(digitalClock.editModeText, DIGITAL_TEXT_FORMAT, timeOfClock.timestamp).getTime()
+        )
+      );
+    }
+    dispatch(_exitEditMode());
+  };
+}
+
+export function isEditModeTextValid(state: DigitalState): boolean {
+  return isMatch(state.editModeText, DIGITAL_TEXT_FORMAT);
+}
+
+export function getDisplayText(timeState: TimeState): string {
+  return format(timeState.timestamp, DIGITAL_TEXT_FORMAT);
+}
Enter fullscreen mode Exit fullscreen mode

For the view components, along with the app, they can be found at review-of-state-management-in-react/02-reducer-like-solutions-redux-and-its-family/d1b2fad.

Review of state management with Redux Toolkit

In RTK, one-state changing logics are defined by slice functions. As a slice function is restricted to changing the one state represented by the slice, what one state a slice function changes can be clearly understood by only checking what one state the slice represents, which makes one-state changing in Redux predictable without tracking logics in any function bodies. On the other hand, multi-state changing logics are defined by multi-state changing actions. Likewise, to understand what states a multi-state changing action changes, what slice functions the action invokes needs to be tracked out by looking into function bodies of the action and other actions invoked by the action. But, the tracking work is as easy as tracking plain functions, which makes multi-state changing in RTK predictable at limited cost. Then, RKT has the same brightest pro as Redux does.

Meanwhile, because one-state changing actions in RTK are exported directly from slices, high coupling between reducer and their one-state changing actions in Redux gets eliminated is RTK. Though, because building and maintaining multi-state changing actions always involve building and maintaining underlying one-state changing actions exported from slices, multi-state changing actions and their underlying slices become high-coupling, which brings difficulties in development, thus overall cost of development becomes high. High coupling between slices and their multi-state changing actions leads to the biggest con of Redux.

Again, a module built with RTK is still not completely modularized by default and introducing a module-wide Redux setup is still costly in RTK. So, incomplete modularization by default is still another major con of RTK.

To sum up, doing state management with RTK achieves predictable states changing at limited cost of only tracking multi-state changing logics but at a bit high overall cost of development.

Example module built with Flux

RTK has done a lot to decrease cost of development but using it is still costly. The 2 cons, high coupling between reducers/slices and their actions and incomplete modularization by default, seem to be intrinsic characters of reducer-like solutions. To figure out how these cons come into being, I would take a look at Redux's parent solution, Flux.

Again, the React app is initialized with create-react-app --template typescript, src/App.tsx is cleared for later use, unused files are removed, the package of time helpers is installed:

$ npx create-react-app 02-reducer-like-solutions-redux-and-its-family/cdaed68 --template typescript
# ...
$ cd 02-reducer-like-solutions-redux-and-its-family/cdaed68
Enter fullscreen mode Exit fullscreen mode
// cdaed68/src/App.tsx
import { FC } from 'react';

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

export default App;
Enter fullscreen mode Exit fullscreen mode
$ rm src/App.css src/App.test.tsx src/logo.svg
Enter fullscreen mode Exit fullscreen mode
$ npm i date-fns
Enter fullscreen mode Exit fullscreen mode

Then, to use Flux, flux is installed. Notice that, Flux has been in maintenance mode and is declared not to be compatible with the latest React(v18), so installing it requires the option --force:

$ npm i --force flux @types/flux
Enter fullscreen mode Exit fullscreen mode

Also, some part of Flux is really not working with the latest React, so a helper createFunctional for Flux is introduced as a replacement of the broken part as follows:

// cdaed68/src/fluxHelpers.tsx
import type { Container } from 'flux/utils';
import React, { FC, useEffect, useState } from 'react';

export function createFunctional<TProps extends React.Attributes, TState>(
  TheFC: FC<TProps & TState>,
  getStores: () => Container.StoresList,
  getState: () => TState
): FC<TProps> {
  return (props) => {
    const [state, setState] = useState(getState());

    useEffect(() => {
      getStores().forEach((s) => s.addListener(() => setState(getState())));
    }, []);

    return <TheFC {...{ ...props, ...state }} />;
  };
}
Enter fullscreen mode Exit fullscreen mode

Then, to match the 3 required states, there would be 3 stores with their actions, TimeStore.ts with TimeActions.ts, AnalogueStore.ts with AnalogueActions.ts, DigitalStore.ts with DigitalActions.ts. And, a dispatcher, ClockDispatcher.ts, is needed for dispatching actions to stores. For data deriving in Flux, it's achieved as a part of stores with the help of the dispatcher's waiting function.

In terms of coding, all stores are dependent on their actions for using the action types and some actions are dependent on stores for accessing states in them, which constitutes circular dependency in some cases. Meanwhile, all stores are dependent on the dispatcher for receiving actions and the dispatcher is dependent on all actions for declaring available actions. To get this coded, I would start from the dispatcher, then evolve the rest parts along with it step by step.

The dispatcher is initially coded as follows:

// cdaed68/src/CompositeClock/ClockDispatcher.ts
import { Dispatcher } from 'flux';

export type ClockAction = {};

export const clockDispatcher = new Dispatcher<ClockAction>();
Enter fullscreen mode Exit fullscreen mode

The store with its actions for the state of time and the adjustment for the dispatcher are coded as follows:

// cdaed68/src/CompositeClock/TimeActions.ts
const NS = 'TIME';

export const ActionTypes = {
  CHANGE_TIMESTAMP: `${NS}-CHANGE_TIMESTAMP`,
} as const;

export type TimeAction = { type: typeof ActionTypes['CHANGE_TIMESTAMP']; timestamp: number };

export function changeTimestamp(timestamp: number): TimeAction {
  return {
    type: ActionTypes.CHANGE_TIMESTAMP,
    timestamp,
  };
}
Enter fullscreen mode Exit fullscreen mode
// cdaed68/src/CompositeClock/ClockDispatcher.ts
import { Dispatcher } from 'flux';
+import type { TimeAction } from './TimeActions';

-export type ClockAction = {};
+export type ClockAction = TimeAction;

export const clockDispatcher = new Dispatcher<ClockAction>();
Enter fullscreen mode Exit fullscreen mode
// cdaed68/src/CompositeClock/TimeStore.ts
import { ReduceStore } from 'flux/utils';
import { ClockAction, clockDispatcher } from './ClockDispatcher';
import { ActionTypes } from './TimeActions';

export interface TimeState {
  timestamp: number;
}

class TimeStore extends ReduceStore<TimeState, ClockAction> {
  constructor() {
    super(clockDispatcher);
  }

  getInitialState(): TimeState {
    return {
      timestamp: 0,
    };
  }

  reduce(state: TimeState, action: ClockAction): TimeState {
    switch (action.type) {
      case ActionTypes.CHANGE_TIMESTAMP:
        return { ...state, timestamp: action.timestamp };
    }
    return state;
  }
}

export const timeStore = new TimeStore();
Enter fullscreen mode Exit fullscreen mode

The store with its actions for the state of the analogue clock and the adjustment for the dispatcher are coded as follows. Because AnalogueStore.ts and AnalogueActions.ts are dependent on each other and they constitue circle dependency, I would code the two iteratively:

// cdaed68/src/CompositeClock/AnalogueActions.ts
const NS = 'ANALOGUE';

export const ActionTypes = {
  ENTER_EDIT_MODE: `${NS}-ENTER_EDIT_MODE`,
  EXIT_EDIT_MODE: `${NS}-EXIT_EDIT_MODE`,
  CHANGE_EDIT_MODE_MINUTE_ANGLE: `${NS}-CHANGE_EDIT_MODE_MINUTE_ANGLE`,
} as const;

export type AnalogueAction =
  | { type: typeof ActionTypes['ENTER_EDIT_MODE'] }
  | { type: typeof ActionTypes['EXIT_EDIT_MODE'] }
  | { type: typeof ActionTypes['CHANGE_EDIT_MODE_MINUTE_ANGLE'] };
Enter fullscreen mode Exit fullscreen mode
// cdaed68/src/CompositeClock/ClockDispatcher.ts
import { Dispatcher } from 'flux';
+import type { AnalogueAction } from './AnalogueActions';
import type { TimeAction } from './TimeActions';

-export type ClockAction = TimeAction;
+export type ClockAction = TimeAction | AnalogueAction;

export const clockDispatcher = new Dispatcher<ClockAction>();
Enter fullscreen mode Exit fullscreen mode
// cdaed68/src/CompositeClock/AnalogueStore.ts
import { ReduceStore } from 'flux/utils';
import { ActionTypes } from './AnalogueActions';
import { ClockAction, clockDispatcher } from './ClockDispatcher';
import { ActionTypes as TimeActionTypes } from './TimeActions';
import { timeStore } from './TimeStore';

const TWO_PI = 2 * Math.PI;

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

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

class AnalogueStore extends ReduceStore<AnalogueState, ClockAction> {
  constructor() {
    super(clockDispatcher);
  }

  getInitialState(): AnalogueState {
    const displayAngles = this.calcDisplayAngles();
    return {
      displayAngles,
      isEditMode: false,
      editModeAngles: displayAngles,
    };
  }

  reduce(state: AnalogueState, action: ClockAction): AnalogueState {
    switch (action.type) {
      case TimeActionTypes.CHANGE_TIMESTAMP:
        clockDispatcher.waitFor([timeStore.getDispatchToken()]);
        return { ...state, displayAngles: this.calcDisplayAngles() };

      case ActionTypes.ENTER_EDIT_MODE:
      case ActionTypes.EXIT_EDIT_MODE:
      case ActionTypes.CHANGE_EDIT_MODE_MINUTE_ANGLE:
    }
    return state;
  }

  calcDisplayAngles(): AnalogueAngles {
    const d = new Date(timeStore.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,
    };
  }
}

export const analogueStore = new AnalogueStore();
Enter fullscreen mode Exit fullscreen mode
// cdaed68/src/CompositeClock/AnalogueActions.ts
+import { analogueStore } from './AnalogueStore';
+import { clockDispatcher } from './ClockDispatcher';
+import { changeTimestamp } from './TimeActions';
+import { timeStore } from './TimeStore';
+
+const TWO_PI = 2 * Math.PI;
+
const NS = 'ANALOGUE';

export const ActionTypes = {
  ENTER_EDIT_MODE: `${NS}-ENTER_EDIT_MODE`,
  EXIT_EDIT_MODE: `${NS}-EXIT_EDIT_MODE`,
  CHANGE_EDIT_MODE_MINUTE_ANGLE: `${NS}-CHANGE_EDIT_MODE_MINUTE_ANGLE`,
} as const;

export type AnalogueAction =
  | { type: typeof ActionTypes['ENTER_EDIT_MODE'] }
  | { type: typeof ActionTypes['EXIT_EDIT_MODE'] }
-  | { type: typeof ActionTypes['CHANGE_EDIT_MODE_MINUTE_ANGLE'] };
+  | { type: typeof ActionTypes['CHANGE_EDIT_MODE_MINUTE_ANGLE']; minuteAngle: number };
+
+export function dispatchEnterEditMode(): void {
+  if (analogueStore.getState().isEditMode) return;
+  clockDispatcher.dispatch({ type: ActionTypes.ENTER_EDIT_MODE });
+}
+
+export function dispatchExitEditMode(submit: boolean = true): void {
+  const analogueState = analogueStore.getState();
+  if (!analogueState.isEditMode) return;
+  if (submit) {
+    const d = new Date(timeStore.getState().timestamp);
+    d.setHours(
+      Math.floor((analogueState.editModeAngles.hour / TWO_PI) * 12) +
+        12 * Math.floor(d.getHours() / 12)
+    );
+    d.setMinutes((analogueState.editModeAngles.minute / TWO_PI) * 60);
+    d.setSeconds((analogueState.editModeAngles.second / TWO_PI) * 60);
+    clockDispatcher.dispatch(changeTimestamp(d.getTime()));
+  }
+  clockDispatcher.dispatch({ type: ActionTypes.EXIT_EDIT_MODE });
+}
+
+export function changeEditModeMinuteAngle(minuteAngle: number): AnalogueAction {
+  return { type: ActionTypes.CHANGE_EDIT_MODE_MINUTE_ANGLE, minuteAngle };
+}
Enter fullscreen mode Exit fullscreen mode
// cdaed68/src/CompositeClock/AnalogueStore.ts
...

class AnalogueStore extends ReduceStore<AnalogueState, ClockAction> {
  ...

  reduce(state: AnalogueState, action: ClockAction): AnalogueState {
    switch (action.type) {
      case TimeActionTypes.CHANGE_TIMESTAMP:
        clockDispatcher.waitFor([timeStore.getDispatchToken()]);
        return { ...state, displayAngles: this.calcDisplayAngles() };

      case ActionTypes.ENTER_EDIT_MODE:
+        return { ...state, isEditMode: true, editModeAngles: state.displayAngles };
      case ActionTypes.EXIT_EDIT_MODE:
+        return { ...state, isEditMode: false };
      case ActionTypes.CHANGE_EDIT_MODE_MINUTE_ANGLE:
+        return {
+          ...state,
+          editModeAngles: {
+            ...state.editModeAngles,
+            minute: (action.minuteAngle + TWO_PI) % TWO_PI,
+            hour:
+              (Math.floor((state.editModeAngles.hour / TWO_PI) * 12) +
+                action.minuteAngle / TWO_PI) *
+              (TWO_PI / 12),
+          },
+        };
    }
    return state;
  }

  ...
}

...
Enter fullscreen mode Exit fullscreen mode

The store with its actions for the state of the digital clock and the adjustment for the dispatcher are coded as follows. Because DigitalStore.ts and DigitalActions.ts are dependent on each other and they constitue circle dependency, I would code the two iteratively:

// cdaed68/src/CompositeClock/DigitalActions.ts
const NS = 'DIGITAL';

export const ActionTypes = {
  ENTER_EDIT_MODE: `${NS}-ENTER_EDIT_MODE`,
  EXIT_EDIT_MODE: `${NS}-EXIT_EDIT_MODE`,
  CHANGE_EDIT_MODE_TEXT: `${NS}-CHANGE_EDIT_MODE_TEXT`,
} as const;

export type DigitalAction =
  | { type: typeof ActionTypes['ENTER_EDIT_MODE'] }
  | { type: typeof ActionTypes['EXIT_EDIT_MODE'] }
  | { type: typeof ActionTypes['CHANGE_EDIT_MODE_TEXT'] };
Enter fullscreen mode Exit fullscreen mode
// cdaed68/src/CompositeClock/ClockDispatcher.ts
import { Dispatcher } from 'flux';
import type { AnalogueAction } from './AnalogueActions';
+import type { DigitalAction } from './DigitalActions';
import type { TimeAction } from './TimeActions';

-export type ClockAction = TimeAction | AnalogueAction;
+export type ClockAction = TimeAction | AnalogueAction | DigitalAction;

export const clockDispatcher = new Dispatcher<ClockAction>();
Enter fullscreen mode Exit fullscreen mode
// cdaed68/src/CompositeClock/DigitalStore.ts
import { format, isMatch } from 'date-fns';
import { ReduceStore } from 'flux/utils';
import { ClockAction, clockDispatcher } from './ClockDispatcher';
import { ActionTypes } from './DigitalActions';
import { ActionTypes as TimeActionTypes } from './TimeActions';
import { timeStore } from './TimeStore';

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

export class DigitalStore extends ReduceStore<DigitalState, ClockAction> {
  static readonly FORMAT = 'HH:mm:ss';

  constructor() {
    super(clockDispatcher);
  }

  getInitialState(): DigitalState {
    const displayText = this.calcDisplayText();
    return {
      displayText,
      isEditMode: false,
      editModeText: displayText,
    };
  }

  reduce(state: DigitalState, action: ClockAction): DigitalState {
    switch (action.type) {
      case TimeActionTypes.CHANGE_TIMESTAMP:
        clockDispatcher.waitFor([timeStore.getDispatchToken()]);
        return { ...state, displayText: this.calcDisplayText() };

      case ActionTypes.ENTER_EDIT_MODE:
      case ActionTypes.EXIT_EDIT_MODE:
      case ActionTypes.CHANGE_EDIT_MODE_TEXT:
    }
    return state;
  }

  calcDisplayText(): string {
    return format(timeStore.getState().timestamp, DigitalStore.FORMAT);
  }

  isEditModeTextValid(): boolean {
    return isMatch(this.getState().editModeText, DigitalStore.FORMAT);
  }
}

export const digitalStore = new DigitalStore();
Enter fullscreen mode Exit fullscreen mode
// cdaed68/src/CompositeClock/DigitalActions.ts
+import { parse } from 'date-fns';
+import { clockDispatcher } from './ClockDispatcher';
+import { DigitalStore, digitalStore } from './DigitalStore';
+import { changeTimestamp } from './TimeActions';
+import { timeStore } from './TimeStore';
+
const NS = 'DIGITAL';

export const ActionTypes = {
  ENTER_EDIT_MODE: `${NS}-ENTER_EDIT_MODE`,
  EXIT_EDIT_MODE: `${NS}-EXIT_EDIT_MODE`,
  CHANGE_EDIT_MODE_TEXT: `${NS}-CHANGE_EDIT_MODE_TEXT`,
} as const;

export type DigitalAction =
  | { type: typeof ActionTypes['ENTER_EDIT_MODE'] }
  | { type: typeof ActionTypes['EXIT_EDIT_MODE'] }
-  | { type: typeof ActionTypes['CHANGE_EDIT_MODE_TEXT'] };
+  | { type: typeof ActionTypes['CHANGE_EDIT_MODE_TEXT']; editModeText: string };
+
+export function dispatchEnterEditMode(): void {
+  if (digitalStore.getState().isEditMode) return;
+  clockDispatcher.dispatch({ type: ActionTypes.ENTER_EDIT_MODE });
+}
+
+export function dispatchExitEditMode(submit: boolean = true): void {
+  const digitalState = digitalStore.getState();
+  if (!digitalState.isEditMode) return;
+  if (submit && digitalStore.isEditModeTextValid()) {
+    clockDispatcher.dispatch(
+      changeTimestamp(
+        parse(
+          digitalState.editModeText,
+          DigitalStore.FORMAT,
+          timeStore.getState().timestamp
+        ).getTime()
+      )
+    );
+  }
+  clockDispatcher.dispatch({ type: ActionTypes.EXIT_EDIT_MODE });
+}
+
+export function changeEditModeText(editModeText: string): DigitalAction {
+  return { type: ActionTypes.CHANGE_EDIT_MODE_TEXT, editModeText };
+}
Enter fullscreen mode Exit fullscreen mode
// cdaed68/src/CompositeClock/DigitalStore.ts
...

export class DigitalStore extends ReduceStore<DigitalState, ClockAction> {
  ...

  reduce(state: DigitalState, action: ClockAction): DigitalState {
    switch (action.type) {
      case TimeActionTypes.CHANGE_TIMESTAMP:
        clockDispatcher.waitFor([timeStore.getDispatchToken()]);
        return { ...state, displayText: this.calcDisplayText() };

      case ActionTypes.ENTER_EDIT_MODE:
+        return { ...state, isEditMode: true, editModeText: state.displayText };
      case ActionTypes.EXIT_EDIT_MODE:
+        return { ...state, isEditMode: false };
      case ActionTypes.CHANGE_EDIT_MODE_TEXT:
+        return { ...state, editModeText: action.editModeText };
    }
    return state;
  }

  ...
}

...
Enter fullscreen mode Exit fullscreen mode

To nicely override initial states, I would declare a getter of the initial states getInitialClockState and its return type aside the dispatcher, then adjust getInitialState of the stores to use it:

// cdaed68/src/CompositeClock/ClockDispatcher.ts
import { Dispatcher } from 'flux';
import type { AnalogueAction } from './AnalogueActions';
+import type { AnalogueState } from './AnalogueStore';
import type { DigitalAction } from './DigitalActions';
+import type { DigitalState } from './DigitalStore';
import type { TimeAction } from './TimeActions';
+import type { TimeState } from './TimeStore';

export type ClockAction = TimeAction | AnalogueAction | DigitalAction;

+export interface ClockState {
+  time: TimeState;
+  analogue: AnalogueState;
+  digital: DigitalState;
+}
+
+export function getInitialClockState(): Partial<ClockState> {
+  return {
+    time: {
+      timestamp: Date.now(),
+    },
+  };
+}
+
export const clockDispatcher = new Dispatcher<ClockAction>();
Enter fullscreen mode Exit fullscreen mode
// cdaed68/src/CompositeClock/TimeStore.ts
import { ReduceStore } from 'flux/utils';
-import { ClockAction, clockDispatcher } from './ClockDispatcher';
+import { ClockAction, clockDispatcher, getInitialClockState } from './ClockDispatcher';
import { ActionTypes } from './TimeActions';

...

class TimeStore extends ReduceStore<TimeState, ClockAction> {
  ...

  getInitialState(): TimeState {
-    return {
-      timestamp: 0,
-    };
+    return (
+      getInitialClockState().time ?? {
+        timestamp: 0,
+      }
+    );
  }

  ...
}

...
Enter fullscreen mode Exit fullscreen mode
// cdaed68/src/CompositeClock/AnalogueStore.ts
import { ReduceStore } from 'flux/utils';
import { ActionTypes } from './AnalogueActions';
-import { ClockAction, clockDispatcher } from './ClockDispatcher';
+import { ClockAction, clockDispatcher, getInitialClockState } from './ClockDispatcher';
import { ActionTypes as TimeActionTypes } from './TimeActions';
import { timeStore } from './TimeStore';

...

class AnalogueStore extends ReduceStore<AnalogueState, ClockAction> {
  ...

  getInitialState(): AnalogueState {
    const displayAngles = this.calcDisplayAngles();
-    return {
-      displayAngles,
-      isEditMode: false,
-      editModeAngles: displayAngles,
-    };
+    return (
+      getInitialClockState().analogue ?? {
+        displayAngles,
+        isEditMode: false,
+        editModeAngles: displayAngles,
+      }
+    );
  }

  ...
}

...
Enter fullscreen mode Exit fullscreen mode
// cdaed68/src/CompositeClock/DigitalStore.ts
import { format, isMatch } from 'date-fns';
import { ReduceStore } from 'flux/utils';
-import { ClockAction, clockDispatcher } from './ClockDispatcher';
+import { ClockAction, clockDispatcher, getInitialClockState } from './ClockDispatcher';
import { ActionTypes } from './DigitalActions';
import { ActionTypes as TimeActionTypes } from './TimeActions';
import { timeStore } from './TimeStore';

...

export class DigitalStore extends ReduceStore<DigitalState, ClockAction> {
  ...

  getInitialState(): DigitalState {
    const displayText = this.calcDisplayText();
-    return {
-      displayText,
-      isEditMode: false,
-      editModeText: displayText,
-    };
+    return (
+      getInitialClockState().digital ?? {
+        displayText,
+        isEditMode: false,
+        editModeText: displayText,
+      }
+    );
  }

  ...
}

...
Enter fullscreen mode Exit fullscreen mode

For the view components, along with the app, they can be found at review-of-state-management-in-react/02-reducer-like-solutions-redux-and-its-family/cdaed68. For a convenient reference, the use of createFunctional is listed as follows:

// cdaed68/src/CompositeClock/CompositeView.tsx
...

export const CompositeView = createFunctional<Props, ClockState>(
  ({ analogue, digital }) => {

    ...

  },
  () => [timeStore, analogueStore, digitalStore],
  () => ({
    time: timeStore.getState(),
    analogue: analogueStore.getState(),
    digital: digitalStore.getState(),
  })
);
Enter fullscreen mode Exit fullscreen mode

Review of state management with Flux

In terms of state management, the only difference between Flux and Redux is, Flux uses stores with reducers included for both one-state changing and data deriving, but Redux uses reducers for one-state changing and selectors for data deriving seperately. The 2 cons of high coupling between reducers and their actions as well as incomplete modularization by default exist in both Flux and Redux, which is very possibly because Redux is evolved from Flux. But does that mean the 2 cons are intrinsic characters of reducer-like solutions? With this question, let me take a look a bit closely.

About high coupling, is a reducer necessarily high-coupling with its actions? What if a reducer processing multiple kinds of actions can be split into multiple reducers processing only one kind of actions? Then, plain payloads of reducers would take the place of actions, so high coupling disappears. A potential challenge can be how to get multi-state changing logics defined by a reducer that handles only one kind of actions, but I don't think it's unresolveable. This assumption indicates, only when a reducer-like solution treats a reducer as a binary operator that processes more than one kind of actions, the reducer would be high-coupling with its actions. As all Redux faimly members are designed in this way but some other ways of designing reducer-like solutions are still possible, high coupling is only an intrinsic character of Redux family but not that of reducer-like solutions.

About incomplete modularization, are states necessarily organized as app-wide ones by default? What if states are organized as module-wide ones by default or there is support to organize states as module-wide ones at low cost? Then, modularization becomes complete. This assumption indicates, only when a reducer-like solution organizes states as app-wide ones by default and doesn't provide good support or module-wide ones, incomplete modularization prones to happen. As some Redux family member like useReducer is not designed in this way, incomplete modularization is not intrinsic character of either Redux family or reducer-like solutions.

Example module built with useReducer

Next, to continue getting a comprehensive understanding of Redux family, let me check out the React's built-in hook useReducer. Because useReducer is evolved from both Flux and Redux but was born after Redux, I would like to regard it as Redux's nephew.

Again, the React app is initialized with create-react-app --template typescript, src/App.tsx is cleared for later use, unused files are removed, the package of time helpers is installed:

$ npx create-react-app 02-reducer-like-solutions-redux-and-its-family/e6ed1b5 --template typescript
# ...
$ cd 02-reducer-like-solutions-redux-and-its-family/e6ed1b5
Enter fullscreen mode Exit fullscreen mode
// e6ed1b5/src/App.tsx
import { FC } from 'react';

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

export default App;
Enter fullscreen mode Exit fullscreen mode
$ rm src/App.css src/App.test.tsx src/logo.svg
Enter fullscreen mode Exit fullscreen mode
$ npm i date-fns
Enter fullscreen mode Exit fullscreen mode

The hook useReducer only manages component-wide states by its original design. To get component-wide states hoisted as module-wide ones, the following helper contextualizeUseReducer is introduced:

// e6ed1b5/src/reducerHelpers.tsx
import {
  createContext,
  Dispatch,
  FC,
  PropsWithChildren,
  useCallback,
  useContext,
  useLayoutEffect,
  useRef,
} from 'react';

export interface ReturnOfContextualizeUseReducer<TState, TAction> {
  TheStateProvider: FC<TheStateProviderProps<TState>>;
  useTheReducer: () => [TState, Dispatch<TAction>];
  useTheStateGetter: () => () => TState;
}

export interface TheStateProviderProps<TState> extends PropsWithChildren {
  initialStateOverride?: TState;
}

export function contextualizeUseReducer<TState, TAction>(
  useRawReducer: (initialState?: TState) => [TState, Dispatch<TAction>]
): ReturnOfContextualizeUseReducer<TState, TAction> {
  const ReducerContext = createContext<[state: TState, dispatch: Dispatch<TAction>] | null>(null);

  const StateGetterContext = createContext<(() => TState) | null>(null);

  const TheStateProvider: FC<TheStateProviderProps<TState>> = ({
    children,
    initialStateOverride,
  }) => {
    const retRawReducer = useRawReducer(initialStateOverride);
    const [state] = retRawReducer;

    const refState = useRef(state);

    const getState = useCallback(() => refState.current, []);

    useLayoutEffect(() => {
      refState.current = state;
    }, [state]);

    return (
      <ReducerContext.Provider value={retRawReducer}>
        <StateGetterContext.Provider value={getState}>{children}</StateGetterContext.Provider>
      </ReducerContext.Provider>
    );
  };

  const useTheReducer = () => {
    const value = useContext(ReducerContext);
    if (!value) {
      throw new Error('ReducerContext not found');
    }
    return value;
  };

  const useTheStateGetter = () => {
    const value = useContext(StateGetterContext);
    if (!value) {
      throw new Error('StateGetterContext not found');
    }
    return value;
  };

  return {
    TheStateProvider,
    useTheReducer,
    useTheStateGetter,
  };
}
Enter fullscreen mode Exit fullscreen mode

Additionally, when some useCallback wrapped function in a view component needs to access a state, it can use the state getter instead of the state directly to avoid getting itself regenerated on rerendered so to avoid performance issues to some degree. Though, a small problem of the state getter is, it can't access the latest state value immediately after an action is dispatched because the state accessed by the getter only gets refreshed on rerendered.

The general thought of the implementation with useReducer is almost the same as that with Redux except that an app-wide setup is not needed.

The reducer with its actions for the state of time is coded as follows:

// e6ed1b5/src/CompositeClock/TimeActions.ts
export const ActionTypes = {
  CHANGE_TIMESTAMP: 'CHANGE_TIMESTAMP',
} as const;

export type TimeAction = { type: typeof ActionTypes['CHANGE_TIMESTAMP']; timestamp: number };

export function changeTimestamp(timestamp: number): TimeAction {
  return {
    type: ActionTypes.CHANGE_TIMESTAMP,
    timestamp,
  };
}
Enter fullscreen mode Exit fullscreen mode
// e6ed1b5/src/CompositeClock/TimeReducer.ts
import { useReducer } from 'react';
import { contextualizeUseReducer } from '../reducerHelpers';
import { ActionTypes, TimeAction } from './TimeActions';

export interface TimeState {
  timestamp: number;
}

const initialState: TimeState = {
  timestamp: 0,
};

function timeReducer(state: TimeState, action: TimeAction): TimeState {
  switch (action.type) {
    case ActionTypes.CHANGE_TIMESTAMP:
      return { ...state, timestamp: action.timestamp };
  }
  return state;
}

const useRawTimeReducer = (initialStateOverride?: TimeState) =>
  useReducer(timeReducer, initialStateOverride ?? initialState);

export const {
  TheStateProvider: TimeStateProvider,
  useTheReducer: useTimeReducer,
  useTheStateGetter: useTimeStateGetter,
} = contextualizeUseReducer(useRawTimeReducer);
Enter fullscreen mode Exit fullscreen mode

The reducer with its actions for the state of the analogue clock is coded as follows. Because AnalogueReducer.ts and AnalogueActions.ts are dependent on each other and they constitue circle dependency, I would code the two iteratively:

// e6ed1b5/src/CompositeClock/AnalogueActions.ts
export const ActionTypes = {
  ENTER_EDIT_MODE: 'ENTER_EDIT_MODE',
  EXIT_EDIT_MODE: 'EXIT_EDIT_MODE',
  CHANGE_EDIT_MODE_MINUTE_ANGLE: 'CHANGE_EDIT_MODE_MINUTE_ANGLE',
} as const;

export type AnalogueAction =
  | { type: typeof ActionTypes['ENTER_EDIT_MODE'] }
  | { type: typeof ActionTypes['EXIT_EDIT_MODE'] }
  | { type: typeof ActionTypes['CHANGE_EDIT_MODE_MINUTE_ANGLE'] };
Enter fullscreen mode Exit fullscreen mode
// e6ed1b5/src/CompositeClock/AnalogueReducer.ts
import { useReducer } from 'react';
import { contextualizeUseReducer } from '../reducerHelpers';
import { ActionTypes, AnalogueAction } from './AnalogueActions';
import type { TimeState } from './TimeReducer';

const TWO_PI = 2 * Math.PI;

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

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

const initialState: AnalogueState = {
  isEditMode: false,
  editModeAngles: { hour: 0, minute: 0, second: 0 },
};

function reducer(state: AnalogueState, action: AnalogueAction): AnalogueState {
  switch (action.type) {
    case ActionTypes.ENTER_EDIT_MODE:
    case ActionTypes.EXIT_EDIT_MODE:
    case ActionTypes.CHANGE_EDIT_MODE_MINUTE_ANGLE:
  }
  return state;
}

const useRawReducer = () => useReducer(reducer, initialState);

export const {
  TheStateProvider: AnalogueStateProvider,
  useTheReducer: useAnalogueReducer,
  useTheStateGetter: useAnalogueStateGetter,
} = contextualizeUseReducer(useRawReducer);

export function getDisplayAngles(timeState: TimeState): AnalogueAngles {
  const d = new Date(timeState.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,
  };
}
Enter fullscreen mode Exit fullscreen mode
// e6ed1b5/src/CompositeClock/AnalogueActions.ts
+import { Dispatch } from 'react';
+import { AnalogueAngles, AnalogueState, getDisplayAngles } from './AnalogueReducer';
+import { changeTimestamp, TimeAction } from './TimeActions';
+import type { TimeState } from './TimeReducer';
+
+const TWO_PI = 2 * Math.PI;
+
export const ActionTypes = {
  ENTER_EDIT_MODE: 'ENTER_EDIT_MODE',
  EXIT_EDIT_MODE: 'EXIT_EDIT_MODE',
  CHANGE_EDIT_MODE_MINUTE_ANGLE: 'CHANGE_EDIT_MODE_MINUTE_ANGLE',
} as const;

export type AnalogueAction =
-  | { type: typeof ActionTypes['ENTER_EDIT_MODE'] }
+  | { type: typeof ActionTypes['ENTER_EDIT_MODE']; editModeAngles: AnalogueAngles }
  | { type: typeof ActionTypes['EXIT_EDIT_MODE'] }
-  | { type: typeof ActionTypes['CHANGE_EDIT_MODE_MINUTE_ANGLE'] };
+  | { type: typeof ActionTypes['CHANGE_EDIT_MODE_MINUTE_ANGLE']; minuteAngle: number };
+
+export function dispatchEnterEditMode(
+  getState: () => AnalogueState,
+  dispatch: Dispatch<AnalogueAction>,
+  getTimeState: () => TimeState
+): void {
+  if (getState().isEditMode) return;
+  const editModeAngles = getDisplayAngles(getTimeState());
+  dispatch({
+    type: ActionTypes.ENTER_EDIT_MODE,
+    editModeAngles,
+  });
+}
+
+export function dispatchExitEditMode(
+  getState: () => AnalogueState,
+  dispatch: Dispatch<AnalogueAction>,
+  getTimeState: () => TimeState,
+  dispatchTime: Dispatch<TimeAction>,
+  submit: boolean = true
+): void {
+  if (!getState().isEditMode) return;
+  if (submit) {
+    const d = new Date(getTimeState().timestamp);
+    d.setHours(
+      Math.floor((getState().editModeAngles.hour / TWO_PI) * 12) +
+        12 * Math.floor(d.getHours() / 12)
+    );
+    d.setMinutes((getState().editModeAngles.minute / TWO_PI) * 60);
+    d.setSeconds((getState().editModeAngles.second / TWO_PI) * 60);
+    dispatchTime(changeTimestamp(d.getTime()));
+  }
+  dispatch({ type: ActionTypes.EXIT_EDIT_MODE });
+}
+
+export function changeEditModeMinuteAngle(minuteAngle: number): AnalogueAction {
+  return { type: ActionTypes.CHANGE_EDIT_MODE_MINUTE_ANGLE, minuteAngle };
+}
Enter fullscreen mode Exit fullscreen mode
// e6ed1b5/src/CompositeClock/AnalogueReducer.ts
...

function reducer(state: AnalogueState, action: AnalogueAction): AnalogueState {
  switch (action.type) {
    case ActionTypes.ENTER_EDIT_MODE:
+      return { ...state, isEditMode: true, editModeAngles: action.editModeAngles };
    case ActionTypes.EXIT_EDIT_MODE:
+      return { ...state, isEditMode: false };
    case ActionTypes.CHANGE_EDIT_MODE_MINUTE_ANGLE:
+      return {
+        ...state,
+        editModeAngles: {
+          ...state.editModeAngles,
+          minute: (action.minuteAngle + TWO_PI) % TWO_PI,
+          hour:
+            (Math.floor((state.editModeAngles.hour / TWO_PI) * 12) + action.minuteAngle / TWO_PI) *
+            (TWO_PI / 12),
+        },
+      };
  }
  return state;
}

...
Enter fullscreen mode Exit fullscreen mode

The reducer with its actions for the state of the digital clock is coded as follows. Because DigitalStore.ts and DigitalActions.ts are dependent on each other and they constitue circle dependency, I would code the two iteratively:

// e6ed1b5/src/CompositeClock/DigitalActions.ts
export const ActionTypes = {
  ENTER_EDIT_MODE: 'ENTER_EDIT_MODE',
  EXIT_EDIT_MODE: 'EXIT_EDIT_MODE',
  CHANGE_EDIT_MODE_TEXT: 'CHANGE_EDIT_MODE_TEXT',
} as const;

export type DigitalAction =
  | { type: typeof ActionTypes['ENTER_EDIT_MODE'] }
  | { type: typeof ActionTypes['EXIT_EDIT_MODE'] }
  | { type: typeof ActionTypes['CHANGE_EDIT_MODE_TEXT'] };
Enter fullscreen mode Exit fullscreen mode
// e6ed1b5/src/CompositeClock/DigitalReducer.ts
import { format, isMatch } from 'date-fns';
import { useReducer } from 'react';
import { contextualizeUseReducer } from '../reducerHelpers';
import { ActionTypes, DigitalAction } from './DigitalActions';
import type { TimeState } from './TimeReducer';

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

export const DIGITAL_TEXT_FORMAT = 'HH:mm:ss';

const initialState: DigitalState = {
  isEditMode: false,
  editModeText: '',
};

function reducer(state: DigitalState, action: DigitalAction): DigitalState {
  switch (action.type) {
    case ActionTypes.ENTER_EDIT_MODE:
    case ActionTypes.EXIT_EDIT_MODE:
    case ActionTypes.CHANGE_EDIT_MODE_TEXT:
  }
  return state;
}

const useRawReducer = () => useReducer(reducer, initialState);

export const {
  TheStateProvider: DigitalStateProvider,
  useTheReducer: useDigitalReducer,
  useTheStateGetter: useDigitalStateGetter,
} = contextualizeUseReducer(useRawReducer);

export function isEditModeTextValid(state: DigitalState): boolean {
  return isMatch(state.editModeText, DIGITAL_TEXT_FORMAT);
}

export function getDisplayText(timeState: TimeState): string {
  return format(timeState.timestamp, DIGITAL_TEXT_FORMAT);
}
Enter fullscreen mode Exit fullscreen mode
// e6ed1b5/src/CompositeClock/DigitalActions.ts
+import { parse } from 'date-fns';
+import { Dispatch } from 'react';
+import {
+  DigitalState,
+  DIGITAL_TEXT_FORMAT,
+  getDisplayText,
+  isEditModeTextValid,
+} from './DigitalReducer';
+import { changeTimestamp, TimeAction } from './TimeActions';
+import type { TimeState } from './TimeReducer';
+
export const ActionTypes = {
  ENTER_EDIT_MODE: 'ENTER_EDIT_MODE',
  EXIT_EDIT_MODE: 'EXIT_EDIT_MODE',
  CHANGE_EDIT_MODE_TEXT: 'CHANGE_EDIT_MODE_TEXT',
} as const;

export type DigitalAction =
-  | { type: typeof ActionTypes['ENTER_EDIT_MODE'] }
+  | { type: typeof ActionTypes['ENTER_EDIT_MODE']; editModeText: string }
  | { type: typeof ActionTypes['EXIT_EDIT_MODE'] }
-  | { type: typeof ActionTypes['CHANGE_EDIT_MODE_TEXT'] };
+  | { type: typeof ActionTypes['CHANGE_EDIT_MODE_TEXT']; editModeText: string };
+
+export function dispatchEnterEditMode(
+  getState: () => DigitalState,
+  dispatch: Dispatch<DigitalAction>,
+  getTimeState: () => TimeState
+): void {
+  if (getState().isEditMode) return;
+  const editModeText = getDisplayText(getTimeState());
+  dispatch({
+    type: ActionTypes.ENTER_EDIT_MODE,
+    editModeText,
+  });
+}
+
+export function dispatchExitEditMode(
+  getState: () => DigitalState,
+  dispatch: Dispatch<DigitalAction>,
+  getTimeState: () => TimeState,
+  dispatchTime: Dispatch<TimeAction>,
+  submit: boolean = true
+): void {
+  if (!getState().isEditMode) return;
+  if (submit && isEditModeTextValid(getState())) {
+    dispatchTime(
+      changeTimestamp(
+        parse(getState().editModeText, DIGITAL_TEXT_FORMAT, getTimeState().timestamp).getTime()
+      )
+    );
+  }
+  dispatch({ type: ActionTypes.EXIT_EDIT_MODE });
+}
+
+export function changeEditModeText(editModeText: string): DigitalAction {
+  return { type: ActionTypes.CHANGE_EDIT_MODE_TEXT, editModeText };
+}
Enter fullscreen mode Exit fullscreen mode
// e6ed1b5/src/CompositeClock/DigitalReducer.ts
...

function reducer(state: DigitalState, action: DigitalAction): DigitalState {
  switch (action.type) {
    case ActionTypes.ENTER_EDIT_MODE:
+      return { ...state, isEditMode: true, editModeText: action.editModeText };
    case ActionTypes.EXIT_EDIT_MODE:
+      return { ...state, isEditMode: false };
    case ActionTypes.CHANGE_EDIT_MODE_TEXT:
+      return { ...state, editModeText: action.editModeText };
  }
  return state;
}

...
Enter fullscreen mode Exit fullscreen mode

For the view components, along with the app, they can be found at review-of-state-management-in-react/02-reducer-like-solutions-redux-and-its-family/e6ed1b5. For a convenient reference, the use of the state providers is listed as follows:

// e6ed1b5/src/CompositeClock/CompositeView.tsx
...

export const CompositeView: FC = () => {
  ...
};

export const CompositeClock: FC = () => {
  return (
    <TimeStateProvider
      initialStateOverride={{
        timestamp: Date.now(),
      }}
    >
      <AnalogueStateProvider>
        <DigitalStateProvider>
          <CompositeView />
        </DigitalStateProvider>
      </AnalogueStateProvider>
    </TimeStateProvider>
  );
};
Enter fullscreen mode Exit fullscreen mode

Review of state management with useReducer

In terms of state management, the only differences between useReducer and Redux are, (1) useReducer only manages component-wide states by its original design, so building a helper to get component-wide states hoisted as module-wide ones is needed, (2) states in useReducer only get refreshed on rerendered, so latest states can't be accessed immediately after an action payload is dispatched, thus write-read state-changing logics can't be coded. Hence, useReducer has the same pros as Redux does but has cons of high coupling between reducers and actions and inability of write-read state-changing logics.

To sum up, doing state management with useReducer achieves predictable states changing at limited cost of only tracking multi-state changing logics but at high overall cost of development and with inability of write-read state-changing logics.

Summary for reducer-like solutions

After getting the example module, the composite clock, rebuilt with the 4 Redux family members, Redux, RTK, Flux and useReducer, it can be concluded that Redux family achieves predictable states changing at limited cost of only tracking multi-state changing logics but at high overall cost of development. Because one-state changing logics are defined by reducers or slice functions and what one state a reducer or a slice function changes can be clearly understood by only taking a look at the reducer's function declaration or what one state the slice represents, one-state changing in Redux family becomes predictable without tracking logics in any function bodies. Because multi-state changing logics are defined by multi-state changing actions and looking into function bodies of a multi-state changing action and other actions invoked by the action to track out what reducers or slice functions it involves or invoke is as easy as tracking plain constants and plain functions, multi-state changing in Redux family becomes predictable at limited cost. Though, because of high coupling between reducers/slices and actions, overall cost of development is high.

In addition, an worth-mentioning insight is, the con of high coupling between reducers and actions as well as the con of incomplete modularization by default are not intrinsic characters of reducer-like solutions, because a reducer doesn't have to process more than one kind of actions, and states don't have to be organized as app-wide ones by default. Although it's still undeniable that Redux family goes much further in state management than MVC pattern, it's also possible to design a lower-cost reducer-like solution.

What's next

By far, important members of Redux family have been reviewed compared with MVC pattern, which reviews reducer-like solutions as a result. Then, in the next article, continuing to answer the question #1 in the last article, I would look into facebook's experiment of state management in React - Recoil.

Top comments (0)