React 상태관리
24/09/30 - 이든/React Deep Dive 5,6장
5장
5.1 상태관리 왜 필요함?
상태란 무엇인가?
react의 상태 관리 역사
Flux 패턴
그러하다!
Redux 등장
Flux 구조를 위해 Elm 아키텍처를 기반으로 만들어진 상태관리 라이브러리
Elm이 뭐임?
flux와 마찬가지로 데이터 흐름을 3가지로 분류 후 단방향으로 강제 시킴
- 모델 : 애플리케이션의 상태
- 뷰 : 모델을 표현하는 HTML
- 업데이트 : 모델(상태)를 수정하는 방식
이렇게 상태 업데이트를 단방향으로 강제시킴
Redux는 Elm 아키텍처의 영향으로
스토어 : 하나의 상태 객체를 담고있음
디스패치 : reducer 함수로 스토어의 상태를 업데이트 함 이때 새로운 객체를 만들어서 전파함
로 나눠서 사용함
단점 : 할게 많다(액션 타입 선언, 액션 수행함수 구현,dispatcher, selector 필요, … 보일러플레이트가 넘 많음)
Context Api와 useContext
props drilling은 피하고 싶은데 redux 보일러플레이트는 싫어 ⇒ context API
초창기에는 getChildContext()
를 통해서 전역상태를 불러다 썼는데 두가지 문제가 있었음
getChildContext
를 호출하면shouldComponentUpdate
가 true가 되어 불필요한 렌더링 발생shouldComponentUpdate
는 boolean값을 반환하는데 props, state가 변경되었을 때 true가 되면서 render()가 실행됨
- context를 인수로 받아야 해서 결합도 향상
참고 : https://legacy.reactjs.org/docs/legacy-context.html
그래서 이런 단점 해결하려고 16.3버전에 새로운 context가 출시됨
createContext
: 컨택스트 생성createContext.Provider
: 컨텍스트 공급자createContext.Consumer
: 컨텍스트 소비자(레거시, class, 하위호환성) ⇒useContext(createContext)
: 훅으로 가져와서 소비함
근데 Context API는 상태를 ‘관리’하지 않음 그냥 ‘주입’할 뿐임
상태관리 라이브러리의 조건이 될 수 있는
- 어떤 상태를 기반으로 다른 상태를 만들 수 있어야 한다
- 필요에 따라 이런 상태 변화를 최적화 할 수 있어야 한다
두가지 다 만족 못함 (p222/3.1장)
⇒ 상태관리를 위한 API가 아님
React query, SWR 탄생
함수 컴포넌트가 인기를 끌며 여러 훅이 나옴 ⇒ 이때 커스텀 훅을 통해 자신들만의 훅들을 만들기 시작함
⇒ 그중 대표적인 커스텀 훅이 React Query, SWR임
둘다 fetch관리에 특화된 라이브러리라 서버상태(HTTP요청)관리에 특화됨 (loading, error, data, …)
Recoil, Zustand, Jotai, Valtio 탄생
Redux와는 다르게 훅을 사용해서 작은 크기의 상태를 효율적으로 관리하는 라이브러리 들이 쏟아짐
⇒ 그래서 까보면 모두 peerDependencies가 react V16.8 이상임
5.1.2 정리
여러 라이브러리(해결책)들이 나오고 있음 ⇒ 이분야가 건강하다
⇒ 필요에 따라 알잘딱깔센하자
5.2장 React hook으로 상태관리 하기
옛날에는 Redux 필수였지만 요즘엔 선택임 <= Context API, useReducer, useState 덕임
type Init<T> = T extends any ? T | ((prev: T) => T) : never;
function useState<T>(init: T) {
const [state, dispatch] = useReducer(
(prev: T, action: Init<T>) =>
typeof action === "function" ? action(prev) : action,
init
);
return [state, dispatch];
}
(반대도 가능함)
둘 다 똑같은 놈들이다
그래서 둘 다 한계가 동일하다
⇒ 훅을 사용할 때마다 컴포넌트 별로 초기화되므로 각각 별개로 돌아간다(local state)
⇒ 공유가 안됨 ⇒ 그래서 prop로 넘겨줌 ⇒ props dirilling 빠밤
한계
useState, useReducer 모두 리액트의 fiber node에 연결된 hooks 연결 리스트 안에 클로저 되어있다 ⇒ 리액트가 관리한다
그럼 리액트가 관리하지 않는 곳에서 클로저 시키면? ⇒ JS 실행 컨텍스트 어딘가에서 관리하고 그곳을 참조하고 있다면?
전역상태관리 직접만들어보기
- 실행 컨텍스트에 저장한 상태객체를 state에 넣어주고
- setState 시 실행 컨텍스트에 저장한 상태객체도 함께 바꿔준다면?
// Store.ts
import React from "react";
export type State = { counter: number };
let state: State = {
counter: 0,
};
export function get(): State {
return state;
}
type Init<T> = T extends any ? T | ((prev: T) => T) : never;
export function set<T>(nextState: Init<T>) {
state = typeof nextState === "function" ? nextState(state) : nextState;
}
// Counter1.tsx
function Counter1() {
const [count, setCount] = useState(state);
function handleClick() {
set((prev: State) => {
const newState = { counter: prev.counter + 1 };
setCount(newState);
return newState;
});
}
return (
<>
<h3>{state.counter}</h3>
<button onClick={handleClick}>+</button>
</>
);
}
// Counter2.tsx
function Counter2() {
const [count, setCount] = useState(state);
function handleClick() {
set((prev: State) => {
const newState = { counter: prev.counter + 1 };
setCount(newState);
return newState;
});
}
return (
<>
<h3>{state.counter}</h3>
<button onClick={handleClick}>+</button>
</>
);
}
쓰레기 코드를 작성했습니다.
- 동일한 상태를 중복으로 관리
- 참조하고 있는 다른 컴포넌트는 액션있기 전까지 렌더링 안함
그럼 제대로된 스토어를 만들러면?
- 컴포넌트 외부에 저장
- 변경 시 사용하는 컴포넌트가 리렌더링 되야함
- 사용하지 않는다면 리렌더링 되지 말아야함
이게 가능하게 수정해보면
// type.ts
export type Init<T> = T extends any ? T | ((prev: T) => T) : never;
export type Store<State> = {
get: () => State; // 매번 변경 되도록
set: (action: Init<State>) => State; // ustState와 동일
subscribe: (callback: () => void) => () => void; // 변경시 callback 실행되도록
};
// createStore.tsx
import { Init, Store } from "./type";
export const CreateStore = <State extends unknown>(
init: Init<State>
): Store<State> => {
let state = typeof init === "function" ? init() : init;
const callbacks = new Set<() => void>();
const get = () => state;
const set = (nextState: State | ((prev: State) => State)) => {
state =
typeof nextState === "function"
? (nextState as (prev: State) => State)(state)
: nextState;
callbacks.forEach((cb) => cb());
return state;
};
const subscribe = (callback: () => void) => {
callbacks.add(callback);
return () => {
callbacks.delete(callback);
};
};
return { get, set, subscribe };
};
export default CreateStore;
// useStore.ts
import { useEffect, useState } from "react";
import { Store } from "../type";
export const useStore = <State extends unknown>(store: Store<State>) => {
const [state, setState] = useState<State>(() => store.get());
useEffect(() => {
const unsubscribe = store.subscribe(() => setState(store.get()));
return unsubscribe;
}, [store]);
return [state, store.set] as const;
};
이렇게 1, 2의 문제를 해결 할 수 있다. 하지만 이대로는 컴포넌트가 store에서 사용하지 않는 값이 변경될때도 리렌더링 되므로 3번에 부합하다.
// useStoreSelector.ts
import { useEffect, useState } from "react";
import { Store } from "../type";
export const useStoreSelector = <State extends unknown, Value extends unknown>(
store: Store<State>,
selector: (state: State) => Value
) => {
const [state, setState] = useState(() => selector(store.get()));
useEffect(() => {
const unsubscribe = store.subscribe(() => {
const value = selector(store.get());
setState(value);
});
return unsubscribe;
}, [store, selector]);
return [state, store.set] as const;
};
이렇게 사용하는 store만 수정 시 리렌더링 시켜주는 방법으로 변경해주면 필요없는 곳은 변경되지 않는다.
(주의할 점은 selector를 컴포넌트 밖에서 선언하거나 useCallback으로 감싸줘야 합니다. 안그러면 리렌더링 될 때마다 함수가 다시 생성되며 subscribe를 반복하기 때문입니다 ⇒ 이는 useSyncExternalStore에서getSnapshot 동작과 비슷한데 여기는 내부에서 메모이제이션 처리를 해줍니다.)
demo보고 오시죠잉
화면 기록 2024-09-27 오후 5.48.14.mov
count1, 2는 useStore를 사용한 컴포넌트로 스토어가 업데이트 될 때 항상 리렌더링 되지만
count3, name은 useSelector를 사용한 컴포넌트로 자신이 사용한 스토어 값이 업데이트 될 때만 리렌더링 되는 것을 볼 수 있다.
이와 동일한 역할을하는 react의 훅이 useSubscription이다.
근데 v18 이상부터는 내부 구현이 useSyncExternalStore
로 되어있다. (이전꺼는 안까보겠습니다…)
실제 동작은 useSyncExternalStoreShimClient에서 확인 가능하다
v18부터 나눠진 이유를 찾아보면 ‘동시성’ 이라는 결론에 도달한다.
여기부터는 할 이야기 많다… 떡밥만 던져놓고 전역상태관리를 이어나가보자…
그러면 모든 문제가 해결된것인가?!
필자는 아쉽게도 아니라고 한다..
새로생긴 문제 : 이 훅과 스토어를 사용하는 구조에서는 반드시 하나의 스토어만 가지게 된다.
물론
const store1 = createStore({ count: 0 });
const store2 = createStore({ count: 0 });
const store3 = createStore({ count: 0 });
이런식으로 스토어를 여러개 만들 수도 있다. 그리고 훅과 스토어가 1:1로 의존관계를 가지고 있기 때문에 이 스토어를 바라보는 useStore를 각각 만들어 주면 가능은 하다.
const Count1 = () => {
const [state1, setState1] = useStore(store1);
const [state2, setState2] = useStore(store2);
const [state3, setState3] = useStore(store3);
...
}
하지만 이런 구조는 인자로 넘겨주는 store가 어디에 들어가는지 각각 관리하며 store1, 2, 3에 어떤 값이 담겨 있는지 기억해야한다
⇒ 이것을 문제라고 인식한다면 어떻게 해결할 수 있을까?
⇒ 이렇게 말한 이유는 나는 이게 문제인가? 라는 생각이 조금 든다.. 각각의 전역 상태별로 store를 나누어 관리하는게 더 편리하다고 생각한다
단, 위의 형식처럼 useStore를 생성이 아닌 다른 상태관리 라이브러리처럼 useCount, useName 이런 식으로 커스텀 훅 내부에 useStore를 사용해서 스토어를 만들고 사용하는 곳에서 각각의 스토어를 호출 하는식으로 해결하면 되지 않을까 싶었다.
5.2.3 useState와 Context를 함께 사용해 전역상태관리 만들어보기
여기서 Context를 도입하면 위의 문제를 해결 할 수 있다.
// CounterStoreProvider.tsx
export type CounterStore = { count: number; text: string };
export const CounterStoreContext = createContext<Store<CounterStore>>(
createStore<CounterStore>({ count: 1, text: "초기값값" })
);
export const CounterStoreProvider = ({
initialState,
children,
}: PropsWithChildren<{ initialState: CounterStore }>) => {
// useRef로 하는 이유는 Provider로 넘겨주는 props가 불필요하게 변경돼서 리렌더링 되는 것을 방지한 것으로
// 이렇게 했을 때 최초 렌더링에서만 스토어를 만들어서 값을 내려줄 것이다.
const storeRef = useRef<Store<CounterStore>>();
// 스토어를 생성한 적이 없으면 최초에 한 번 생성함
if (!storeRef.current) {
storeRef.current = createStore(initialState);
}
return (
<CounterStoreContext.Provider value={storeRef.current}>
{children}
</CounterStoreContext.Provider>
);
};
이렇게 Context를 사용해서 Provider를 만들어주고
// useCounterContextSelector.ts
export const useCounterContextSelector = <State extends unknown>(
selector: (store: CounterStore) => State
) => {
const store = useContext(CounterStoreContext);
// useSubscription
const [subscription, _] = useStoreSelector(store, useCallback(selector, []));
return [subscription, store.set] as const;
};
useContext를 사용해서 CounterStore값을 핸들링 해주면
(책에는 useSubscription으로 구현되어있음 - feea6b22)
function NavigationBar() {
return (
<>
<ContextCounter />
<ContextInput />
<CounterStoreProvider initialState=8>
<ContextCounter />
<ContextInput />
<CounterStoreProvider initialState=8>
<ContextCounter />
<ContextInput />
</CounterStoreProvider>
</CounterStoreProvider>
<CounterStoreProvider initialState=8>
<ContextCounter />
<ContextInput />
</CounterStoreProvider>
</>
);
}
이렇게 store를 사용할 구역별로 관리해서 store를 지정하고 사용할 수 있다. 이걸로 얻게 되는 장점은
- 스토어가 어디서 온건지 신경 안써도 됨
- context와 provider를 관리하는 부모입장에서는 자식 컴포넌트에 따라 보여주고 싶은 데이터를 context로 잘 격리하면 됨
- 부모와 자식 컴포넌트의 책임과 역할을 이름이 아닌 명시적인 코드로 나눌 수 있어 코드 작성이 용이해짐
라고 하는데 2, 3은 크게 공감은 안된다…
그리고 1번은 위의 스토어별로 useStore를 만드는 각각의 훅을 만들어서 관리하는게 더 명시적이고 사용하기 편하지 않나? 라고 생각한다 recoil, zustand 모두 그런 식으로 만들어져있고..
5.2.4장 Recoil, Jotai, Zustand 살펴보기
현재 각광 받고 있는 3개의 상태관리 라이브러리를 비교해보자
Recoil, Jotail : context, Provider 기반으로 가능한 작은 상태를 관리
Zustand, Redux : 하나의 큰 스토어를 기반으로 상태를 관리 ⇒ context 기반이 아닌 스토어가 가지는 클로저를 기반으로 생성 + 상태 변경시 구독하고 있는 컴포넌트에 전파
Recoil
최소 상태 개념인 atom이라는 이론을 가장 먼저 선보였으며 20년도에 만들어졌으니 아직도 v1이 안나왔다ㅋㅋ,,
위의 코드를 최적화 해논 느낌이라 함께 책 보시죵 notifyComponents : https://github.com/facebookexperimental/Recoil/blob/main/packages/recoil/core/Recoil_RecoilRoot.js#L124
→ 구독중인 컴포넌트에 변경을 전파
getDownstreamNodes: https://github.com/facebookexperimental/Recoil/blob/main/packages/recoil/core/Recoil_FunctionalCore.js#L233
→ 종속된 노드를 모두 찾음
nodeToComponentSubscriptions : https://github.com/facebookexperimental/Recoil/blob/main/packages/recoil/core/Recoil_State.js#L91
→ Map에 저장된 노드를 Key로 찾아서 O(1)로 찾을 수 있음
Jotai
WeakMap(https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/WeakMap) 을 활용해서 recoil에서 단일 key를 가져야하는 atom을 개선했음
Zustand
우리가 쓰고 있는 거니깐 좀 더 살펴보시죵
위에 구현한 방식처럼 Zustand도 바닐라js로 Store를 구현했다. 그래서 react의 렌더링을 시켜줘야하는데 ./src/react.ts에서 관리되고 있는 useStore와 create가 그 역할을 해준다
- createStore : 바닐라js로 만든 스토어
- create: react에서 사용할 수 있도록 createStore를 랩핑하고 useBoundStore의 api 를 복사해서 사용함
- createWithEqualityFn : create에 상태 비교 함수를 추가하여 비교로직을 커스텀 할 수 있음
V4 기준으로는 useStore
와useSyncExternalStoreWithSelector
는 타입이 다르게 구현되었을 뿐 구현체는 동일합니다.
다만 create 되는 부분에서 equalityFn
값을 강제하냐가 다릅니다.
V5에서는 useStore
에 변경점이 있습니다.
-
equalityFn
를 제거변경이유는 https://github.com/pmndrs/zustand/discussions/1937 에서 확인 할 수 있습니다.
-
기존의
useSyncExternalStoreWithSelector
가 아닌 create의 내부구현에 사용된useStore
는useSyncExternalStore
반환- 두 훅의 차이는 스토어 전체를 비교 후 리렌더링하냐, 아니면 스토어중 특정 값을 비교하고 리렌더링하냐로 보인다. (
useSyncExternalStore
도 selector를 props로 넘겨주면 특정 값 비교가 가능하지만 이를 내재화 한 느낌?? 좀 더 알아봐야할 것 같습니다,,,)
- 두 훅의 차이는 스토어 전체를 비교 후 리렌더링하냐, 아니면 스토어중 특정 값을 비교하고 리렌더링하냐로 보인다. (
그리고 이건 별개의 이야기인데 개인적으로 상태를 관리하기 위해 봐야하는 곳이 많아진다는 문제가 생기기 때문에 전역 상태가 많아지는건 조심해야 한다고 생각합니다,,,
참고 :
- https://www.epicreact.dev/one-react-mistake-thats-slowing-you-down
- https://kentcdodds.com/blog/state-colocation-will-make-your-react-app-faster
6장
6.3 리액트 개발 도구 활용
Components 탭
컴포넌트 트리를 확인 가능, props와 내부 hook에 대한 정보 확인 가능
componets의 도구
- ! : 강제 에러 발생
- 타이머 : Suspense로 감싸져 있는 컴포넌트 디버깅 용인듯??
- 눈 : 해당 요소로 이동
- 벌레 : 컴포넌트 세부사항 콘솔에 찍어줌
- <> : 소스코드 확인 함
Profiler 탭
리액트가 렌더링하는 과정에서 발생하는 상황을 확인 하는 도구이다.
설정
- General : ‘Highlight updates when components render.’ 체크하면 렌더링 표시됨
- Debugging : ‘Hide logs during additional invocations in Strict Mode’ 체크하면 strict Mode 해제됨
- Profiler : ‘Record why each component rendered while profiling.’ 체크하면 컴포넌트가 렌더링 된 이유를 기록한다 ⇒ App 속도는 조금 느려지지만 디버깅에 큰 도움이 된다.
Flamepraph
렌더 커밋별로 어떤 작업이 발생했는지 표시함(너비와 렌더링 시간이 비례)
(랜더링이 되지 않은 컴포넌트는 회색으로 표시되고 호버하면 ‘Did not render’라고 뜸)
Ranked
해당 커밋에서 렌더링하는 데 오랜 시간이 걸린 컴포넌트를 순서대로 나열함
(렌더링이 발생한 컴포넌트만 보여줌)
Timeline
렌더링을 시간의 순서대로 보여줌 (react v18이상부터만 가능)
ex) 프로파일러로 디버깅 해보기
문제 상황 -
FeedStickyTabBar
컴포넌트가 렌더링 된 이유
- 초기 렌더링
- hook 27 changed
- the parent component rendered
원인 분석
초기 렌더링
ㅇㅇ 당연
hook 27 changed
Components 탭에서 FeedStickyTabBar
를 살펴보면 hook 27을 알 수 있다.
근데 27번이 없다..???
임시로 테스트를 해봤다
...
const [tmp, setTmp] = useState('이든')
...
useEffect(function test() {
setTimeout(() => setTmp('재훈'), 1000)
}, [])
...
그 결과 dev tools가 거짓말 한 것을 알 수 있었다
useEffect로 setState 했을 경우
profiler ⇒ 21번째 훅
componenets ⇒ 19번째 훅
그래도 근소한 차이를 보면 useGetOpacityTabScroll()
를 불러오면서 2이 발생했을 것이니 직접 확인해보자
the parent component rendered
useGetOpacityTabScroll()
에서 하나씩 빼보면 원인이 useDeferredValue
임을 찾았다.
스크롤 위치에 따라서 변하는 state로 인한 렌더링은 지연시기키 위해 useDeferredValue사용했지만… 실패했다. (웹상으로는 잘 작동하지만 앱으로 확인해보면 빠바박 거리는게 보인다. 렌더링을 지연시킨다는 것은 렌더링 시키는 cpu성능에 따라 다르게 작동하는 것 같다 ⇒ 아니었다^^ 그냥 scrollTo 이벤트 실행, state 업데이트 타이밍 이슈였음^^)
따라서 제거해주면 2번 렌더링 이유만 남는다
그렇다고 해결인 된것은 아님..
댓글남기기