훅을 활용하여 마이크로 상태 관리하기
2024/03/10
n°52
category : React
☼
2024/03/10
n°52
category : React

Jotai의 개발자 Daishi Kato가 쓴 ‘리액트 훅을 활용한 마이크로 상태관리’ 를 읽고 정리한 내용이다. code snippet을 직접 작성하고, 기억할만한 내용을 선별하고, 글의 순서를 편집해 작성하였다. 지역상태/전역 상태의 구분, context api, 전역 상태가 어떤 문제를 공유하고 각각 어떻게 해결해가는지를 중점으로 작성했다. 이 글을 통해 복잡할 수 있는 전역 상태의 관리를 효과적으로 실천할 수 있기를 바라며…
저자가 제안하는 상태 관리를 위한 방법론이다. 목적 지향형인 리액트 훅과 Redux와 같은 중앙 집중형 상태 관리의 한계를 개선하기 위한 방법을 제시한다.
저자는 대안으로 범용적인 상태관리를 제시한다. 1) 가벼운 상태 관리 2) 개발자가 요구사항에 맞는 적절한 상태 관리법을 선택할 수 있도록 한다. 범용적인 상태관리는 다음과 같은 필수 기능을 포함한다.
효과적인 상태 관리를 위해 상태를 '지역 상태’와 '전역 상태’로 구분한다.
리액트에서 지역 상태란
지역상태를 위해 useState가 사용된다. 혹은 지난 글에서 살펴봤듯이, 상태가 크거나 상태 변경의 로직이 늘어날 때 useReducer를 사용할 수 있다. 두 훅과 커스텀 훅은 이전 글에서 충분히 다루었으니, 지역 상태 훅들의 다음과 같은 공통점을 살펴보자.
지역 상태를 사용할 때
상태가 컴포넌트 단위로 독립되어 있어 상태 변경이 일어난 컴포넌트에서만 리렌더링을 한다. (리액트의 주요 설계 목적과도 부합한다.) 독립된 컴포넌트 내의 상태 변경은 다른 컴포넌트에 영향을 미치지 않는다. 이를 이용하여 상태를 효과적으로 관리하고 성능을 최적화할 수 있다.
지역 상태 훅의 특성
1 초기화 (지연 초기화)
2 베일아웃 (bailout)
useReducer의 초기화, bailout 예시
/* init은 초기화를 위한 함수 */
const init = (count:number):State => ({ count, text:'' })
const reducer = (state:State, action:Action)=> {
switch(action.type) {
case 'INCREMENT':
return { ...state, count: state.count + 1 }
case 'DECREMENT':
return { ...state, count: state.count -1 }
case 'TEXT':
if(!action.text){
/* 1 - bailout */*
return state
}
return { ...state, text: action.text }
default:
throw new Error("unknown action type")
}
}
// Component.tsx
const Component = () => {
/* 2 - 지연 초기화 */*
const [state, dispatch] = useReducer(reducer, 0, init)
return (
<div>
<p>count: {state.count}</p>
<button onClick={() => dispatch({type: 'INCREMENT'})}>
PLUS
</button>
<button onClick={() => dispatch({type: 'DECREMENT'})}>
MINUS
</button>
<input
onChange={(e) => dispatch({ type:'TEXT', text: e.target.value })} />
</div>
)
}
1. Bailout
2. 지연 초기화
리액트 지역 상태를 사용하기
상태 끌어올리기
내용 끌어올리기
참고: 리렌더링이 일어나는 4가지 경우
예시
상태 끌어올리기
// Parent 컴포넌트
function Parent() {
const [sharedState, setSharedState] = React.useState("");
return (
<div>
<ChildA sharedState={sharedState} setSharedState={setSharedState} />
<ChildB sharedState={sharedState} />
</div>
);
}
// ChildA 컴포넌트
function ChildA({ sharedState, setSharedState }) {
return <input value={sharedState} onChange={(e) => setSharedState(e.target.value)} />;
}
// ChildB 컴포넌트
function ChildB({ sharedState }) {
return <div>Current Value: {sharedState}</div>;
}
내용 끌어올리기
// 상태가 있는 컴포넌트
function StatefulComponent() {
const [counter, setCounter] = React.useState(0);
return (
<div>
<button onClick={() => setCounter(counter + 1)}>Increment</button>
<div>Counter: {counter}</div>
<div>This is static content that does not change.</div>
</div>
);
}
// 상태 변경에 영향을 받지 않는 정적 컴포넌트
function StaticContent() {
return <div>This is static content that does not change.</div>;
}
function ContentLiftedComponent() {
<>
<StatefulComponent/>
<StaticContent/>
{/* 혹은 StatefulComponent에 children props를 추가하여 수정할 수 있다.*/}
<StatefulComponent>
<StaticContent/>
</StatefulComponent>
</>
}
모든 컴포넌트가 하나의 상태에 의존하면(ex:최상단의 상태가 앱 전체의 컴포넌트에 상태를 props로 내려줌), 지역상태와 전역상태를 명확히 구분할 수 없다. 일반적으로 리액트에서 '전역상태’란 떨어진 컴포넌트에서 '공유된 상태shared state’이다.
전역상태의 두가지 측면
리액트는 16.3부터 context api를 제공한다. Context Provider, useContext 훅을 사용하여 간단하게 전역상태를 사용할 수 있게 되었다. 책에서는 컨텍스트 api의 기본적인 사용법과 함께, 근본적인 문제와 한계, 모범사례를 살펴본다. 이 챕터에서 중요한 부분은 컨텍스트의 한계이다. 여기서 소개되는 문제의식과 해결법은 뒷장에서 소개하는 전역상태 라이브러리들이 같은 문제를 해결하기 위해 어떤 접근법을 취하고 있는지 이해하는데 도움이 된다. 이 챕터에서 설명하는 store, subscribe, selector같은 개념은 여러 라이브러리에서 유사한 개념과 기능을 공유하기 때문에 숙지하는 것이 좋다.
useState를 사용하거나 정적인 값을 사용하여 컨텍스트를 생성할 수 있다. 상태를 공유하는 컴포넌트를 provider로 묶어주어야 한다. context provider에는 정적인 값이나 useState, useReducer의 상태를 넣어줄 수 있다. Provider value prop에 정적인 값이 들어간다면 전역상태를 변경할 수 없게 된다. (정확히는 업데이트할 방법이 없어진다.)
// 컨텍스트를 정적인 값으로 사용하는 경우
const SampleContext = createContext('black')
const SampleContext = createContext({color: 'black', count: 0, setColor:(param:string) => void })
const Root = () => {
const [color, setColor] = useState('black') // state 훅
return (
<>
<SampleContext.Provider value={{ color, setColor, count:0 }}>
<SampleContext.Provider value={color}> // 정적인 값 사용 시
<ColorComponent />
<MemoColorComponent />
<CountComponent />
<MemoCountComponent />
<DummyComponent />
<MemoDummyComponent />
</ColorContext.Provider>
</>
)
}
const ColorComponent = () => {
const color = useContext(SampleContext) // 정적
const {color, setColor} = useContext(SampleContext) // state 훅
return (
<div>
<p>current color : {color}</p>
<input onChange={(e) => setColor(e.target.value)} />
</div>
)
}
const CountComponent = () =>{
const {count} = useContext(SampleContext)
return <>{count}</>
}
const DummyComponent = () => (<>dummy</>)
const MemoColorComponent = memo(ColorComponent)
const MemoCountComponent = memo(CountComponent)
const MemoDummyComponent = memo(DummyComponent
컨텍스트 provider와 상태훅을 사용해 컨텍스트를 업데이트할 수 있다. 이 때 프로바이더 내부의 모든 컴포넌트가 리렌더링된다. 이 경우 상태 변경이 이루어지지 않는 컴포넌트까지 불필요하게 리렌더링이 일어난다. 이를 방지하기 위해 앞서 살필 '내용 끌어올리기’나 혹은 memo훅을 사용할 수 있다.
인풋 태그에 텍스트를 입력하여 컨텍스트를 업데이트 시 다음과 같은 컨텍스트 전파가 일어난다.
간단한 방법은 Provider 나누는 것이다. 컨텍스트는 프로바이더를 경계로 공유되기 때문에, 공유하려는 상태가 아닌 컴포넌트는 Provider 경계 밖으로 분리할 수도 있다. 하지만 요구사항에 따라 혹은 이미 개발된 컴포넌트의 설계에 따라 그럴 수 없는 경우 또한 자주 있을 수 있다.
1, 2번은 위의 예제를 통해 설명된다. setColor를 통해 바뀐 상태는 color 밖에 없음에도, CountComponent와 React.memo로 감싸진 MemoCountComponent가 리렌더링된다. 또한 DummyComponent도 Provider에 제공된 값이 바뀌면서 리렌더링된다. 컨텍스트에 의존하는 컴포넌트가 많고, 상태 변경이 자주 이루어진다면 불필요한 리렌더링이 페이지 단위로 불필요한 리렌더링이 무분별하게 실행될 것이다.
앞선 객체의 일부 컨텍스트를 사용할 시 리렌더링되는 문제를 해결하기 위해서 컨텍스...