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