이전의 리액트 생태계에서는 상태 관리를 위해 리덕스에 의존했다면 현재는 Context API, useReducer,useState의 등장으로 컴포넌트 내부에 걸쳐서 상태를 관리할 수 있는 방법과 리덕스 이외에 다른 상태 관리 라이브러리가 등장했다.
리액트 16.8에서 등장한 훅과 함수 컴포넌트의 패러다임에서 애플리케이션 내부 상태관리와 새로운 라이브러리에는 어떤 것들이 있는지 정리해보았다.
const useCounter = (initCounter: number = 0) => {
const [counter, setCounter] = useState(initCounter);
const inc = () => {
setCounter(prev => prev + 1);
};
return { counter, inc };
};
const Counter1 = ({ counter, icn }: { counter: number; icn: () => void }) => {
return (
<>
<h3> Counter1 : {counter}</h3>
<button onClick={icn}> +</button>
</>
);
};
const Counter2 = ({ counter, icn }: { counter: number; icn: () => void }) => {
return (
<>
<h3> Counter2 : {counter}</h3>
<button onClick={icn}> +</button>
</>
);
};
const Home = () => {
const { counter, inc } = useCounter();
return (
<div>
<Counter1 counter={counter} icn={inc} />
<Counter2 counter={counter} icn={inc} />
</div>
);
};
지역 상태인 useCounter를 부모 컴포넌트 Home 으로 끌어올려 이 하위 값을 하위 컴포넌트에서 참조해 재사용 하게끔 만들면, 컴포넌트가 동일한 상태를 사용할 수는 있지만, props 형태로 필요한 컴포넌트에 제공하는 것은 여러 컴포넌트에 걸쳐서 공유하기 위해서는 트리 설계가 복잡해짐
export type State = { counter: number };
// 상태를 컴포넌트 밖에 선언, 각 컴포넌트는 해당 state 를 바라본다
let state: State = { counter: 0 };
// useState 와 동일하게 구현, 게으른 초기화를 위한 함수를 받거나 값을 받을 수 있음
type Initializer<T> = T extends any ? T | ((pre: T) => T) : never;
export const getter = (): State => {
console.log("getter", state);
return state;
};
export const setter = (nexttState: Initializer<T>) => {
console.log("setter", state);
state = typeof nexttState === "function" ? nexttState(state) : state;
};
const Counter = () => {
const state = getter();
const handleClick = () => {
setter((prev: State) => ({ counter: prev.counter + 1 }));
console.log("handelClick", state);
};
return (
<>
<h3>{state.counter}</h3>
<button onClick={handleClick}>+ </button>
</>
);
};
실행 결과
console.log 로 찍어 보았을 때 setter 에서는 새로운 값이 잘 불려와지지만, 컴포넌트를 리렌더링 되지 않는다.
이 컴포넌트는 함수 컴포넌트의 재실행(호출), 부모 함수 리렌더링, useState 의 두번째 인수 호출 등 리렌더링 장치가 없다
const Counter1 = () => {
const [count, setCount] = useState(state);
const handleClick = () => {
setter((pre: State) => {
const newState = { counter: pre.counter + 1 };
setCount(newState);
return newState;
});
};
return (
<>
<h3> counter1: {count.counter}</h3>
<button onClick={handleClick}> +</button>
</>
);
};
const Counter2 = () => {
const [count, setCount] = useState(state);
const handleClick = () => {
setter((pre: State) => {
const newState = { counter: pre.counter + 1 };
setCount(newState);
return newState;
});
};
return (
<>
<h3> counter2: {count.counter}</h3>
<button onClick={handleClick}> +</button>
</>
);
};
const Counter = () => {
return (
<>
<div>
<Counter1 />
<Counter2 />
</div>
</>
);
실행 결과
원하는 값을 안정적으로 렌더링 하지만, 같은 상태를 바라보는 반대쪽 컴포넌트에서는 렌더링되지 않는다.
반대 쪽 컴포넌트는 버튼을 눌러야 그제서야 렌더링되어 최신값을 불러 오는 것을 확인
함수 외부에서 상태를 참조하고, 이를 통해 렌더링까지 자연스럽게 일어나러면 다음과 같은 조건을 만족해야함
1) 꼭 window 나 global 에 있어야 할 필요는 없지만, 컴포넌트 외부 어딘가에 상태를 두고 여러 컴포넌트가 같이 써야한다
2) 이 외부에 있는 상태를 사용하는 컴포넌트 상태의 변화를 알아챌 수 있어야하고, 상태가 변화될 때 마다 리렌더링이 일어나서 컴포넌트를 최신 상태 값 기준으로 렌더링 해야 한다. (이 상태를 참조하는 모든 컴포넌트에 해당)
3) 상태가 원시 값이 아닌 객체인 경우 그 객체에 내가 감지하지 않는 값이 변한다 하더라도 리렌더링이 발생해서는 안된다.
// useState 와 동일하게 구현, 게으른 초기화를 위한 함수를 받거나 값을 받을 수 있음
type Initializer<T> = T extends any ? T | ((pre: T) => T) : never;
// 상태는 객체, 원시값 일 수도 있다
type Store<State> = {
get: () => State;
set: (action: Initializer<State>) => State;
subscribe: (callback: () => void) => () => void; // 2 번의 조건을 만족하기 위해 store 값이 변경 될 때 마다 알려주는 callback 함수 실행
};
export const createStore = <State extends unknown>(initialState: Initializer<State>): Store<State> => {
// state 의 값은 스토어 내부에서 보관해야 하므로 변수로 선언
// 초기값은 게으른 초기화를 위한 함수 또한 그냥 값을 받을 수 있도록 선언
let state = typeof initialState! == "function" ? initialState : initialState();
// callbacks 는 자료형에 상관없이 유일한 값을 지정할 수 있는 Set 을 사용한다.
const callbacks = new Set<() => void>();
const get = () => state;
const set = (nexttState: State | ((prev: State) => State)) => {
// 인수가 함수라면 실행하여 새로운 값을 받도록, 아니라면 새로운 값 그대로 사용
state = typeof nexttState === "function" ? (nexttState as (prev: State) => State)(state) : nexttState;
// 값의 설정이 발생하면 callbacks 돌면서 저장되어있는 콜백 모두 시행( ex. useState 실행하여 스토어 업데이트, 컴포넌트의 렌더링이 유도될 것이다.
callbacks.forEach(callback => callback());
return state;
};
// 콜백 함수를 인수로 받아서 받은 함수를 목록에 추가
const subscribe = (callback: () => void) => {
callbacks.add(callback);
//클린 업 실행 시 이를 삭제해서 반복적으로 추가 되는 것을 막는다
// useEffect 의 클린업 함수와 같은 역할
return () => {
callbacks.delete(callback);
};
};
return { get, set, subscribe };
};
// 인수로 사용할 store 를 받는다
const useStore = <State extends unknown>(store: Store<State>) => {
// useState가 컴포넌트의 렌더링을 유도
const [state, setState] = useState<State>(() => store.get());
// useEffect는 store 의 값을 가져와 setState를 수행하는 함수는 store 의 subscribe에 등록된 함수를 실행
// useStore 내부에서는 store 의 값이 변경될 때 마다 state 의 값이 변경되는 것을 보장 받는다
useEffect(() => {
const unsubscribe = store.subscribe(() => {
setState(store.get());
return unsubscribe;
});
}, [store]);
return [state, store.set] as const;
};
const store = createStore({ count: 0 });
const Counter1 = () => {
const [state, setState] = useStore(store);
const handleClick = () => {
setState(pre => ({ count: pre.count + 1 }));
};
return (
<>
<h3>Counter1 : {state.count}</h3>
<button onClick={handleClick}>+</button>
</>
);
};
const Counter2 = () => {
const [state, setState] = useStore(store);
const handleClick = () => {
setState(pre => ({ count: pre.count + 1 }));
};
return (
<>
<h3>Counter2 : {state.count}</h3>
<button onClick={handleClick}>+</button>
</>
);
};
const Home = () => {
return (
<div>
<Counter1 />
<Counter2 />
</div>
);
};
const store = createStore({ count: 0 });
const Counter1 = () => {
const [state, setState] = useStore(store);
const handleClick = () => {
setState(pre => ({ count: pre.count + 1 }));
};
return (
<>
<h3>Counter1 : {state.count}</h3>
<button onClick={handleClick}>+</button>
</>
);
};
실행 결과
Counter 1, Counter2 가 store의 상태 변화에 의해 동시에 두 컴포넌트가 모두 정상적으로 리렌더링 되는 것을 확인 우리가 알고있는 스토어의 형태로 결과가 나타났다!
- useState, useReducer 가 가지고 있는 한계, 컴포넌트 내부에서만 사용할 수 있는 지역 상태라는 점을 극복하기 위해 외부 어딘가에 상태를 저장해 둘 수 있고, 혹은 격리된 자바스크립트 스코프 어딘가일 수도있다.
2.이 외부의 상태 변경을 각자의 방식으로 감지해 컴포넌트 렌더링을 일으킨다