Follow Redux

Tsynringe + reflect-decorator 를 활용해서 Redux와 비슷한 단일 스토어 상태관리 도구를 만들어 보자.

Base Store

/* eslint-disable no-underscore-dangle */
export type Action<P = unknown> = {
  type: string;
  payload?: P;
};

export type Reducer<S, P> = (state: S, action: Action<P>) => S;

export type Reducers<S, P> = Record<string, Reducer<S, P>>;

type Listener = () => void;

export default class BaseStore<S> {
  private listeners = new Set<Listener>();

  public reducer: Reducer<S, unknown>;

  // eslint-disable-next-line no-useless-constructor
  constructor(private _state: S, reducers: Reducers<S, any>) {
    this.reducer = (state: S, action: Action<unknown>) => {
      const reducer = Reflect.get(reducers, action.type);
      if (!reducer) {
        return state;
      }

      return reducer(state, action);
    };
  }

  get state() {
    return this._state;
  }

  dispatch(action: Action<unknown>) {
    this._state = this.reducer(this._state, action);
    this.publish();
  }

  protected publish() {
    this.listeners.forEach((listener) => listener());
  }

  addListener(listener: Listener) {
    this.listeners.add(listener);
  }

  removeListener(listener: Listener) {
    this.listeners.delete(listener);
  }
}

Store

import { singleton } from 'tsyringe';

import BaseStore, { Reducer } from './baseStore';

const initialState = {
  count: 0,
  name: 'taewoong',
};

export type State = typeof initialState;

const increaseReducer: Reducer<State, number> = (state, action) => {
  switch (action.type) {
    case 'increase':
      return {
        ...state,
        count: state.count + (action.payload ?? 1),
      };

    default:
      return state;
  }
};

const decreaseReducer: Reducer<State, number> = (state, action) => {
  switch (action.type) {
    case 'decrease':
      return {
        ...state,
        count: state.count - (action.payload ?? 1),
      };

    default:
      return state;
  }
};

const nameReducer: Reducer<State, string> = (state, action) => {
  switch (action.type) {
    case 'updateName':
      return {
        ...state,
        name: action.payload ?? '',
      };

    default:
      return state;
  }
};

const reducers = {
  increase: increaseReducer,
  decrease: decreaseReducer,
  updateName: nameReducer,
};

export function increase(payload?: number) {
  return { type: 'increase', payload };
}

export function decrease(payload?: number) {
  return { type: 'decrease', payload };
}

@singleton()
export default class Store extends BaseStore<State> {
  // eslint-disable-next-line no-useless-constructor
  constructor() {
    super(initialState, reducers);
  }
}

useDispatch

import { container } from 'tsyringe';

import Store from '../stores/store';
import { Action } from '../stores/baseStore';

export default function useDispatch() {
  const store = container.resolve(Store);

  return (action: Action) => store.dispatch(action);
}

useSelector

import { useRef, useEffect } from 'react';
import { container } from 'tsyringe';

import Store, { State } from '../stores/store';

import useForceUpdate from './useForceUpdate';

type Selector<T> = (state: State) => T;

export default function useSelector<T>(selector: Selector<T>) {
  const store = container.resolve(Store);

  const beforeState = useRef(selector(store.state));

  const forceUpdate = useForceUpdate();

  useEffect(() => {
    const update = () => {
      const newState = selector(store.state);
// 셀렉터를 통해 달라진 상태값을 사용하는, 즉 데이터를 실제 사용하는 곳에서만 forecUpdate가 진행되도록 최적화
// 셀렉터를 객체로 사용하는 경우는 추가적으로 깊은 비교 하도록 로직이 추가 되어야 함
      if (newState !== beforeState.current) {
        forceUpdate();
        beforeState.current = newState;
      }
    };

    store.addListener(update);

    return () => {
      store.removeListener(update);
    };
  }, [store, forceUpdate]);

  return selector(store.state);
}

Usage

CounterController.tsx

import useDispatch from '../hooks/useDispatch';
import { decrease, increase } from '../stores/store';

export default function CounterController() {
  const dispatch = useDispatch();

  const handleClickIncrease = (step?: number) => () => {
    dispatch(increase(step));
  };

  const handleClickDecrease = (step?: number) => () => {
    dispatch(decrease(step));
  };

  return (
    <div>
      <button type="button" onClick={handleClickIncrease(10)}>
        Increase 10
      </button>
      <button type="button" onClick={handleClickIncrease()}>
        Increase
      </button>
      <button type="button" onClick={handleClickDecrease(10)}>
        Decrease 10
      </button>
      <button type="button" onClick={handleClickDecrease()}>
        Decrease
      </button>
    </div>
  );
}

Counter.tsx

import useSelector from '../hooks/useSelector';

export default function Counter() {
  const count = useSelector((state) => state.count);

  return (
    <div>
      <p>{`counter: ${count}`}</p>
    </div>
  );
}

Last updated