React module state (1)
본 글은 Daishi Kato의 Micro State Management with React Hooks의 일부분을 요약하고 정리한 글입니다. 본문에 나온 코드는 github에서 살펴볼 수 있습니다.
module state란?
- module scope에 정의한 state
let count = 0; // 👈 module state라고 볼 수 있다.
const Counter = () => {
/* ... */
};
- module state의 목적은 여러 컴포넌트가 같은 state를 공유하도록 하기 위해서다.
Approach 1
- 컴포넌트 바깥에 변수를 정의한다고 해서 이 것이 module state가 되는 것이 아니다.
- 왜냐하면 변수의 값을 직접 바꾼다고 해도 렌더링이 일어나지 않기 때문이다.
- 렌더링을 하기 위해서는 리엑트의 라이프사이클에 개입 하여 렌더링을 일으켜 줘야 하는데 이를 가능하게 해주는 hook이
useState
와useReducer
다. - 따라서 다음과 같이 작성해 볼 수 있다.
let count = 0;
export const Counter1 = () => {
const [_, setState] = useState(count);
return (
<div className="counter">
<h1>{count}</h1>
<div className="buttonGroup">
<button
onClick={() => {
count -= 1;
setState(count);
}}
>
-1
</button>
<button
onClick={() => {
count += 1;
setState(count);
}}
>
+1
</button>
</div>
</div>
);
};
- module state에 useState를 사용하여 state를 업데이트하면서 rendering을 트리거 했다.
- 그런데 이 방식의 문제점은 module state가 sharable하지 않는 다는 점이다.
- 무슨 말이냐면 한 컴포넌트에서 state를 업데이트 하면 이 state를 사용하고 있는 다른 컴포넌트가 리렌더링하지 않는다.
- 왜냐하면 당연하게도 다른 컴포넌트에서는 set함수가 invoke되지 않았기 때문
- 그래서 위와 같은 현상이 발생한다. 한쪽의 set이 다른 컴포넌트의 리렌더링을 발생시키지 않는다.
Approach 2
- 이를 해결하기 위해서는 한 쪽에서 set이 invoke되면 해당 state를 사용하고 있는 컴포넌트에도 set이 invoke되어야 한다.
- daish kato는 이를 위해 setState 함수를 담을 Set 데이터 구조를 만들고 컴포넌트의 set함수들을 여기다 담는다.
- 그리고 한 쪽에서 set함수를 호출할 때, Set데이터 구조를 순환하여 같은 인자를 넣어 전부 호출한다. 코드를 보면 간단하다.
const setStateFunctions = new Set<(count: number) => void>(); // 👈 setState 함수를 담을 구조를 생성
export const Counter1 = () => {
const [_, setState] = useState(count);
useEffect(() => {
setStateFunctions.add(setState);
return () => {
setStateFunctions.delete(setState);
};
}, []);
const increment = () => {
count += 1;
setStateFunctions.forEach((setState) => {
setState(count);
});
};
const decrement = () => {
count -= 1;
setStateFunctions.forEach((setState) => {
setState(count);
});
};
return (
<div className="counter">
{// 생략 }
</div>
);
};
export const Counter2 = () => {
const [_, setState] = useState(count);
useEffect(() => {
setStateFunctions.add(setState); // 👈 여기서 set함수를 담는다. 일종의 구독관계를 형성
return () => {
setStateFunctions.delete(setState);
};
}, []);
const increment = () => {
count += 1;
setStateFunctions.forEach((setState) => { // 👈 Set을 전부 돌아 다른 컴포넌트의 렌더링을 유발한다.
setState(count);
});
};
const decrement = () => {
count -= 1;
setStateFunctions.forEach((setState) => {
setState(count);
});
};
return (
<div className="counter">
{// 생략}
</div>
);
};
- 이렇게 하면 sharable한 module state를 만들 수 있는데 문제는 코드가 중복된다.
Approach 3
- 코드의 중복을 해결하기 위해 daish kato는 store와 subscription model을 제안한다.
- 간단하게 말하면 module state인 store가 있고 callback을 store에 subscribe할 수 있다.
- 만약 store에 있는 state가 변하면 subscribe한 callback이 호출된다.
const unsubscribe = store.subscribe(() => {
// store의 state가 변하면 이 callback이 호출된다.
});
- store의 팩토리 함수를 살펴보면 다음과 같다.
type Store<T> = {
getState: () => T;
setState: (nextState: T | ((prev: T) => T)) => void;
subscribe: (cb: () => void) => () => void;
};
export const createStore = <T extends unknown>(initialState: T): Store<T> => {
let state = initialState;
const callbacks = new Set<() => void>();
return {
getState: () => state,
setState: (nextState) => {
state =
typeof nextState === "function"
? (nextState as (prev: T) => T)(state)
: nextState;
callbacks.forEach((cb) => cb());
},
subscribe: (cb) => {
callbacks.add(cb);
return () => {
callbacks.delete(cb);
};
},
};
};
- store는
getState
,setState
,subscribe
세 가지 메소드를 가진다. - store의 setState를 살펴보면 Approach 2 에서 살펴본 것 처럼 Set을 순환하여 subscribe된 콜백을 호출한다.
- 그리고 이를 컴포넌트 렌더링 과정에 "hook" 하기 위하여 useStore라는 훅을 만들 수 있다.
export const useStore = <T>(
store: Store<T>
): [T, (nextState: T | ((prev: T) => T)) => void] => {
const [state, setState] = useState<T>(store.getState());
useEffect(() => {
// add subscription
const unsubscribe = store.subscribe(() => {
setState(store.getState()); // 🔥 useStore를 컴포넌트에서 사용하면 그 컴포넌트는 store에 subscribe하게 된다.
});
setState(store.getState()); // 👈 초기 store의 state를 반영하기 위함.
return unsubscribe;
}, [store]); // 👈 store가 변할 때 마다 리액트의 useState 훅을 통해 렌더링을 유발한다.
return [state, store.setState];
};
- 컴포넌트에서는 다음과 같이 사용한다
export const countStore = createStore({ count: 0 });
export const Counter1 = () => {
const [countState, setCountState] = useStore<{ count: number }>(countStore);
const decrement = () => {
setCountState((prev) => ({
count: prev.count - 1,
}));
};
const increment = () => {
setCountState((prev) => ({
count: prev.count + 1,
}));
};
return (
<div className="counter">
{ // 생략 }
</div>
);
};
- module state를 정의하고 훅을 통해서 관리를 할 수 있게 되었다.
- module state에 subscribe하는 모델의 개념적인 부분은 observer pattern에서 더 배울 수 있다.
- 그런데 이 방식의 문제점은 extra re rendering이 발생할 수 있다는 것이다.
- 예를 들어 state가 여러 개의 properties를 가지고 있는 객체인 경우 한 쪽에서 하나의 property만 수정해도 이를 사용하지 않은 다른 컴포넌트도 렌더링을 하게 된다.
- 이를 해결하기 위해 daish kato는 selector를 제안한다.
- 이 부분은 react module state (2) 에서 다룬다.