5.1 상태 관리는 왜 필요한가?
- 상태는 어떠한 의미를 지닌 값으로 애플리케이션의 시나리오에 따라 지속적으로 변경될 수 있는 값을 의미한다.
- 웹 서비스에서 보여지는 거의 모든 부분을 이 상태로 관리한다.
5.1.1 리액트 상태 관리의 역사
Flux 패턴의 등장
- 리액트처럼 SPA의 경우, 하나의 페이지로 앱을 다뤄야 하다보니 단 하나의 Controller가 여러가지 View와 Model을 관리하면서 복잡해졌다.
- 한 View에서 일어난 상호작용 때문에 여러 Model이 변경되거나, 그 반대의 일이 벌어지는 부수효과도 여러 발생하여 어디서 발생한건지 추적이 어렵다보니 유지보수에도 안 좋다.
양방향 데이터 바인딩이 아닌 단방향으로 데이터 흐름을 변경함
- 액션(action) : 어떤 작업을 처리할 액션과 그 액션 발생 시 함께 포함시킬 데이터를 의미한다. 액션 타입과 데이터를 각 정의해 dispatcher로 전달한다.
- 디스패처(dispatcher) : 콜백 함수 형태로 액션이 정의한 타입과 데이터를 모두 store로 보내는 역할을 한다.
- 스토어(store) : 실제 상태의 값과 상태를 변경할 수 있는 메서드를 가지고 있다.
- 뷰(view) : 리액트 컴포넌트에 해당하는 부분으로, 스토어에서 만들어진 데이터를 가져와 화면에 렌더링하는 역할을 한다.
이때 웹에서 사용자가 View를 통해 클릭 같은 Action을 발생시키는데 그 흐름은 아래와 같다.
리덕스의 등장
Flux 구조 구현 + Elm 아키텍처 도입
*Elm은 웹페이지를 선언적으로 작성하기 위한 언어이다.
- 모델(model) : 애플리케이션의 상태를 의미한다.
- 뷰(view) : 모델을 표현하는 HTML을 말한다.
- 업데이트(update) : 모델을 수정하는 방식을 말한다.
- 리덕스는 하나의 상태 객체를 스토어에 저장하고 이 객체를 업데이트 하는 작업을 디스패치해 업데이트를 수행한다.
- 위 작업은 reducer 함수로 발생시킬 수 있는데, 이 함수의 실행은 웹 애플리케이션 상태에 대한 완전히 새로운 복사본을 반환한 뒤, 애플리케이션에 이 새롭게 만들어진 상태를 전파한다.
Context API와 useContext
- 단방향 데이터 흐름은 부모 컴포넌트에 있는 상태를 자식 컴포넌트에서 사용하기 위해선 props로 상태를 계속 내려줘야 한다는 단점이 있다(props drilling).
- props로 상태를 넘겨주지 않고 원하는 곳에 Context Provider가 주입하는 상태를 바로 사용할 수 있는 Context API가 등장한다.
- 함수형 컴포넌트에서는 Context 값을 편리하게 사용할 수 있도록 도와주는 useContext가 등장한다.
- 상태 관리가 아닌 주입을 도와주는 기능이며, 렌더링을 막아주는 기능 또한 존재하지 않아 사용 시 주의가 필요하다.
React Query와 SWR
- 외부에서 데이터를 불러오는 fetch를 관리하는데 특화된 라이브러리이며, API 호출에 대한 상태를 관리하고 있기 때문에 HTTP 요청에 특화된 상태 관리 라이브러리라 볼 수 있다.
import useSWR from 'swr'
const fetcher = (url) => fetch(url).then((res) => res.json())
export default function App() {
const { data, error} = useSWR(
'https://api.github.com/repos/vercel/swr',
fetcher,
)
if(error) return 'An error has occuerred.'
if(!data) return 'Loading...'
return (
<div>
<p> {JSON.stringify(data)} </p>
</div>
)
}
- useSWR의 첫번째 인수에는 조회할 API 주소를, 두번째 인수로는 조회에 사용되는 fetch를 넘겨준다. 첫번째 인수인 API 주소는 키로도 사용되며, 이후 다른 곳에서 동일한 키로 호출하면 재조회하는 것이 아닌 useSWR이 관리하고 있는 캐시의 값을 활용한다.
- 이러한 점에서 SWR이나 리액트 쿼리도 상태 관리 라이브러리의 일종이라 볼 수 있다.
5.2 리액트 훅으로 시작하는 상태 관리
5.2.1 가장 기본적인 방법: useState와 useReducer
- useState를 사용하여 커스텀 훅을 만들어 어디서든 재사용할 수 있다는 큰 장점이 있다.
- 훅 내부에서 관리해야 하는 상태가 복잡하거나 상태를 변경할 수 있는 시나리오가 다양해진다면 훅으로 코드를 분리, 격리해 제공할 수 있다.
- useState와 useReducer을 기반으로 한 훅은 사용할 때마다 컴포넌트별로 초기화되므로 컴포넌트에 따라 서로 다른 상태를 갖는 상태의 파편화 현상이 발생한다.
- 이 지역 상태(local state)는 해당 컴포넌트 내에서만 유효하다는 한계가 있다.
5.2.2 지역 상태의 한계를 벗어나기 : useState의 상태를 바깥으로 분리하기
- 이 한계는 useState가 리액트의 클로저 내부에서 관리되기 때문에 발생한다 .
- 저자는 이 문제를 해결하기 위해 useState의 상태를 외부로 분리하는 방법을 제안하며, 상태가 다른 자바스크립트 실행 문맥에서 초기화되고 관리될 수 있다면 더 넓은 스코프 내에서 객체의 값을 공유할 수 있다고 설명한다.
- 이 접근법을 구현하기 위해서는 상태가 컴포넌트 외부에 위치해야 하며, 정상적으로 리렌더링이 되어야 한다는 조건이 있다 .
- 상태를 외부에서 자연스럽게 참조하고 렌더링하기 위해서는 세 가지 조건을 만족해야 한다.
첫 번째 조건은 여러 컴포넌트가 함께 사용할 수 있도록, 컴포넌트 외부에 상태가 존재해야 한다.
두 번째 조건은 상태가 변할 때, 해당 상태를 참조하는 컴포넌트가 최신 값을 반영하기 위해 리렌더링되어야 한다.
마지막 조건은 객체 상태의 속성이 변해도 그 속성을 참조하는 컴포넌트가 없다면, 해당 컴포넌트는 렌더링이 되지 않아야 한다.
- 예를 들어, A 컴포넌트와 B 컴포넌트가 각각 스몰 A와 스몰 B라는 상태를 공유하고 있을 때, 스몰 A의 값이 변경되어도 B 컴포넌트는 리렌더링되지 않아야 한다.
// 상태 타입 정의
export type State = { counter: number }
// 상태를 컴포넌트 외부에 선언하여 상태를 전역적으로 관리
let state: State = {
counter: 0,
}
// 상태를 반환하는 함수 (getter)
// 컴포넌트에서 상태를 읽을 때 사용
export function get(): State {
return state
}
// 초기 값 또는 이전 상태를 기반으로 새로운 상태를 계산하는 함수 둘 다 허용
type Initializer<T> = T extends any ? T | ((prev: T) => T) : never
// 상태를 갱신하는 함수 (setter)
// 초기화 함수 또는 새 값을 받아 상태를 업데이트
export function set<T>(nextState: Initializer<T>) {
// nextState가 함수라면 이전 상태를 인자로 전달하여 새로운 상태 계산
// 아니라면 nextState를 그대로 상태로 설정
state = typeof nextState === 'function' ? (nextState as (prev: T) => T)(state) : nextState
}
function Counter() {
// 현재 상태를 가져옴
const state = get()
// 버튼 클릭 시 실행될 이벤트 핸들러 함수
function handleClick() {
// 이전 상태를 기반으로 counter 값을 1 증가시킴
set((prev: State) => ({ counter: prev.counter + 1 }))
}
return (
<>
<h3>{state.counter}</h3>
<button onClick={handleClick}>+</button>
</>
)
}
- 위와 같은 방법으로는 컴포넌트가 리렌더링되지 않는다.
- 리렌더링을 일으키기 위한 방법
- useState, useReducer의 반환값 중 두 번째 인수가 어떻게든 호출된다.
- 부모 컴포넌트가 리렌더링되거나 해당 컴포넌트가 다시 실행되어야 한다.
import React, { useState } from "react";
// 전역 상태로 관리되는 state 객체
let state = { counter: 0 };
// 상태를 반환하는 함수 (전역 상태 접근)
function get() {
return state;
}
// 상태를 업데이트하는 함수 (전역 상태 변경)
function set(nextState: (prev: typeof state) => typeof state) {
state = nextState(state);
}
function Counter1() {
// state를 초기값으로 사용하여 `count`라는 로컬 상태 생성
const [count, setCount] = useState(state);
function handleClick() {
// 전역 상태를 업데이트하고 로컬 상태도 동기화
set((prev: typeof state) => {
const newState = { counter: prev.counter + 1 }; // counter 값을 1 증가시킨 새로운 상태 생성
setCount(newState); // 로컬 상태 업데이트 (React 상태 변경으로 다시 렌더링)
return newState; // 전역 상태도 업데이트
});
}
return (
<>
<h3>{state.counter}</h3>
<button onClick={handleClick}>+</button>
</>
);
}
function Counter2() {
// state를 초기값으로 사용하여 `count`라는 로컬 상태 생성
const [count, setCount] = useState(state);
function handleClick() {
// 전역 상태를 업데이트하고 로컬 상태도 동기화
set((prev: typeof state) => {
const newState = { counter: prev.counter + 1 }; // counter 값을 1 증가시킨 새로운 상태 생성
setCount(newState); // 로컬 상태 업데이트 (React 상태 변경으로 다시 렌더링)
return newState; // 전역 상태도 업데이트
});
}
return (
<>
<h3>{state.counter}</h3>
<button onClick={handleClick}>+</button>
</>
);
}
- state는 전역 상태로 관리되지만, 컴포넌트 내부에서도 로컬 상태(count)로 관리하고 있다. 이는 코드의 복잡성을 증가시킨다.
- <h3>{state.counter}</h3>에서 전역 상태를 직접 표시하기 때문에 setCount로 로컬 상태를 업데이트해도 제대로 렌더링되지 않을 수 있다.
- React는 전역 변수의 변경을 감지하지 않기 때문에 전역 상태만 변경하면 컴포넌트가 다시 렌더링되지 않습니다.
- 외부 상태를 참조하고 렌더링까지 자연스럽게 일어나게 하는 방법
- 컴포넌트 외부 어딘가에 상태를 두고 여러 컴포넌트가 같이 쓸 수 있어야 한다.
- 컴포넌트가 상태 변화를 알아챌 수 있어야 하고, 상태가 변화될 때마다 리렌더링이 일어나야 한다.(상태를 참조하는 모든 컴포넌트에서 동일해야 한다.)
- 상태가 객체인 경우, 감지하지 않는 값이 변하면 리렌더링이 발생하면 안된다.
// **`Store` 구현 및 사용자 정의 Hook (`useStore`) 설명**
// 전역 상태를 관리하고, 상태 변화에 따라 컴포넌트를 자동으로 렌더링하도록 설계된 상태 관리 시스템입니다.
// 아래 코드와 주석으로 각 부분의 작동 방식을 쉽게 설명합니다.
import { useState, useEffect } from 'react';
type Initializer<T> = T extends any ? T | ((prev: T) => T) : never;
// **Store 인터페이스**
// 상태를 읽고(get), 설정(set), 상태 변화를 감지하여 callback을 등록(subscribe)하는 인터페이스
type Store<State> = {
get: () => State; // 현재 상태를 반환
set: (action: Initializer<State>) => State; // 상태를 업데이트
subscribe: (callback: () => void) => () => void; // 상태 변화 감지 및 구독 해제를 위한 함수 반환
};
// **`createStore` 함수**
// 초기 상태(`initialState`)를 받아서 새로운 Store를 생성합니다.
export const createStore = <State extends unknown>(
initialState: Initializer<State> // 초기 상태 혹은 초기 상태를 반환하는 함수
): Store<State> => {
// 1. 상태 초기화
// 초기 상태가 함수면 실행 결과를 상태로 사용
let state = typeof initialState !== 'function' ? initialState : initialState();
// 2. 상태 변화 감지를 위한 callback 저장소
// 중복을 허용하지 않고 고유한 callback만 저장할 수 있는 `Set` 사용
const callbacks = new Set<() => void>();
// 3. 상태 읽기 함수
const get = () => state;
// 4. 상태 설정 함수
const set = (nextState: State | ((prev: State) => State)) => {
// 상태 업데이트 로직
state =
typeof nextState === 'function'
? (nextState as (prev: State) => State)(state) // 함수 형태의 업데이트
: nextState; // 직접 값 설정
// 상태 변경 시 모든 등록된 callback 실행
callbacks.forEach((callback) => callback());
return state; // 업데이트된 상태 반환
};
// 5. 구독 관리 함수
const subscribe = (callback: () => void) => {
// 새로운 callback 등록
callbacks.add(callback);
// 구독 해제 함수 반환
return () => {
callbacks.delete(callback); // 해당 callback 제거
};
};
// 6. Store 반환
return { get, set, subscribe };
};
// **`useStore` 사용자 정의 Hook**
// Store의 상태를 React 상태로 연결하여 컴포넌트에서 사용할 수 있게 해줌
export const useStore = <State extends unknown>(store: Store<State>) => {
// 1. React 상태와 Store 상태 초기화
const [state, setState] = useState<State>(() => store.get());
// 2. 구독을 통한 상태 변화 감지
useEffect(() => {
// 상태 변경 시 Store에서 호출되는 callback 등록
const unsubscribe = store.subscribe(() => {
setState(store.get()); // 상태 변경 감지 후 React 상태 업데이트
});
// 컴포넌트 언마운트 시 구독 해제
return unsubscribe;
}, [store]); // store가 바뀌면 Effect 재실행
// 3. React 상태와 Store의 `get` 함수 반환
return [state, store.get] as const; // 타입 추론 보장을 위해 `as const`
};
- 스토어가 객체라면 어떤 값이 바뀌든지 간에 리렌더링이 일어난다. 아래와 같이 수정할 수 있다.
// **`useStoreSelector` 사용자 정의 Hook**
// Store의 상태 중 특정 값만 선택적으로 가져와 React 상태로 관리할 수 있도록 해주는 Hook입니다.
// 상태 변화에 따라 선택된 값만 React 상태로 업데이트됩니다.
export const useStoreSelector = <State extends unknown, Value extends unknown>(
store: Store<State>, // 상태를 관리하는 Store
selector: (state: State) => Value // Store 상태에서 특정 값을 추출하는 함수
) => {
// 1. 선택된 값으로 React 상태 초기화
const [state, setState] = useState(() => selector(store.get()));
// 2. 구독을 통해 상태 변화 감지 및 React 상태 업데이트
useEffect(() => {
// 상태 변경 시 실행되는 callback 등록
const unsubscribe = store.subscribe(() => {
const value = selector(store.get()); // Store에서 선택된 값 가져오기
setState(value); // React 상태 업데이트
});
// 컴포넌트 언마운트 시 구독 해제
return unsubscribe;
}, [store, selector]); // store나 selector가 변경되면 Effect 재실행
// 3. 선택된 React 상태 반환
return state;
};
- 이제 store가 객체로 구성되어 있어도 필요한 값만 select해서 사용할 수 있다.
- 두 번째 인수인 selector를 컴포넌트 밖에 선언하거나, useCallback을 사용해 참조를 고정시켜야 한다.
- 컴포넌트 내에 selector 함수를 생성하고 useCallback으로 감싸두지 않으면 컴포넌트가 리렌더링될 때마다 함수가 계속 재생성된다.
5.2.3 useState와 Context를 동시에 사용해보기
- Context를 활용해 해당 스토어를 하위 컴포넌트에 주입한다면 컴포넌트에서는 자신이 주입된 스토어에 대해서만 접근할 수 있게 된다.
- 이 방식은 반드시 하나의 스토어만 가지게 되며 각 스토어는 마치 전역 변수처럼 작동하여 동일한 형태의 여러 개의 스토어를 가질 수 없게 된다.
- Context로 컴포넌트 트리 내 상태 격리가 필요한 부분에 Provider 생성한다. 각기 다른 초기값을 설정하여 개별 관리한다.
// Context를 생성하면 초기값으로 스토어를 생성하여 설정한다.
// 스토어는 상태를 관리하며, 이를 통해 상태와 상태 변경 로직을 제공한다.
export const CounterStoreContext = createContext<Store<CounterStore>>(
createStore<CounterStore>({ count: 0, text: 'hello' }), // 초기값으로 count: 0, text: 'hello' 설정
)
export const CounterStoreProvider = ({
initialState, // 초기 상태 값 (Provider가 관리할 기본 상태)
children, // 하위 컴포넌트들
}: PropsWithChildren<{
initialState: CounterStore // CounterStore 형태의 초기 상태를 받음
}>) => {
// storeRef는 스토어를 한 번만 생성하기 위해 사용된다.
const storeRef = useRef<Store<CounterStore>>()
// 최초 렌더링 시 storeRef에 스토어를 생성하여 저장한다.
if (!storeRef.current) {
storeRef.current = createStore(initialState) // 스토어 초기화
}
return (
// Context.Provider로 스토어를 하위 컴포넌트들에게 전달
<CounterStoreContext.Provider value={storeRef.current}>
{children}
</CounterStoreContext.Provider>
)
}
export const useCounterContextSelector = <State extends unknown>(
selector: (state: CounterStore) => State, // 상태에서 특정 값을 선택하는 함수
) => {
// Context에서 스토어를 가져옴. 없으면 에러가 발생할 수 있음.
const store = useContext(CounterStoreContext)
// 선택된 상태의 구독 로직을 관리한다.
const subscription = useSubscription(
useMemo(
() => ({
getCurrentValue: () => selector(store.get()), // 현재 스토어의 상태에서 선택된 값을 가져옴
subscribe: store.subscribe, // 상태 변경 시 다시 호출되도록 구독
}),
[store, selector], // store와 selector가 변경되면 다시 메모이제이션
)
)
return [subscription, store.set] as const // 선택된 상태값과 스토어의 set 함수를 반환
}
const ContextCounter = () => {
const id = useId() // 각 컴포넌트 인스턴스에 고유 ID를 생성
const [counter, setCounter] = useCounterContextSelector(
useCallback((state: CounterStore) => state.count, []), // count 상태를 선택
)
// 버튼 클릭 시 count를 증가시키는 함수
function handleClick() {
setCounter((prev) => ({ ...prev, count: prev.count + 1 })) // count 상태를 1 증가
}
// 컴포넌트 렌더링 시 로그 출력
useEffect(() => {
console.log(`${id} Counter Rendered`)
})
return (
<div>
{counter} <button onClick={handleClick}>+</button>
</div>
)
}
- ContextCounter는 CounterStoreContext를 통해 count 상태를 관리.
- 버튼 클릭 시 상태를 업데이트하고 변경된 값에 따라 컴포넌트가 리렌더링됨.
- useCounterContextSelector로 필요한 상태만 구독하여 불필요한 리렌더링을 최소화.
서로 다른 context를 바라보게 할 수 있다.
export default function App(){
return (
<>
<ContextCounter /> // 출력: 0 (전역 초기값 또는 Context 없음)
<CounterStoreProvider initialState={{ count: 10, text: 'hello' }}>
<ContextProvider /> // 출력: 10 (첫 번째 Provider의 상태 사용)
<CounterStoreProvider initialState={{ count: 20, text: 'welcome' }}>
<ContextProvider /> // 출력: 20 (중첩된 Provider의 상태 사용)
</CounterStoreProvider>
</CounterStoreProvider>
</>
)
}
CounterStoreProvider는 React의 Context.Provider를 활용하여 상태를 제공한다.
- initialState로 전달된 값이 해당 Provider 하위의 모든 컴포넌트에서 접근 가능한 상태로 설정된다.
- 중첩된 Provider는 자신의 하위 컴포넌트에만 영향을 미치며, 상위 상태는 덮어씌워지지 않는다.
ContextProvider는 가장 가까운 CounterStoreProvider의 상태를 참조한다.
- React는 Context의 계층 구조를 따라 가장 가까운 Provider를 찾는다.
상위 Provider는 하위 Provider의 상태에 영향을 주지 않는다.
- 중첩 구조에서는 내부의 Provider가 독립적인 상태를 관리한다.
5.2.4 상태 관리 라이브러리 Recoil, Jotai, Zustand 살펴보기
- Recoil, Jotai은 Context, Provider 훅을 기반으로 가능한 작은 상태를 관리한다.
- Zustand는 리덕스와 비슷하게 하나의 큰 스토어를 기반으로 상태를 관리한다(클로저기반).
Recoil
- 컴포넌트는 Recoil에서 제공하는 훅을 통해 atom의 상태 변화를 구독하고, 값이 변경되면 forceUpdate와 같은 기법을 통해 리렌더링을 실행해 최신 atom 값을 return 한다.
RecoilRoot
- 애플리케이션 최상단에 RecoilRoot를 생성한다.
- RecoilRoot로 생성된 Context 스토어에 상태 값을 저장한다.
- 스토어의 상태값에 접근할 수 있는 함수들이 있으며, 이 함수를 활용해 상태값에 접근, 변경이 가능하다.
- 값의 변경이 발생하면 참조하고 있는 하위 컴포넌트에 모두 알린다.
atom
- 상태를 나타내는 Recoil의 최소 상태 단위이다.
- key 값을 필수로 가진다.
- default는 atom의 초깃값이다.
useRecoilValue
const stateValue = useRecoilValue('key 값')
- atom의 값을 읽어오는 훅이다.
- 내부 useEffect를 통해 recoilValue 변경 시 forceUpdate를 통해 렌더링을 강제 수행한다.
useRecoilState
const [stateValue, setStateValue] = useRecoilState('key 값')
- 값을 가져오거나 변경할 수 있는 훅이다.
- 가져오는 값은 useRecoilValue로 사용한다.
- 업데이트는 useSetRecoilState로 수행한다. 내부에 있는 setRecoilValue는 queueOrPerformStateUpdate 함수를 호출해 상태를 업데이트하거나 업데이트가 필요한 내용을 등록한다.
Jotai
- 상향식 접근법이다.
- 작은 단위의 상태를 위로 전파할 수 있는 구조다.
- Context의 문제점인 불필요한 리렌더링 문제를 해결하기 위해 설계됐다.
- Recoil과 달리 별도의 키 관리가 필요없고, 객체의 참조를 통해 값을 관리한다.
- selector 없이 atom만으로 파생된 값 생성이 가능하다.
atom
- Recoil과 같은 최소 상태 단위이다.
- atom 하나로 파생된 상태까지 생성 가능하다.
- key 값이 필요없다.
- useAtomValue에 상태를 저장한다.
useAtomValue
- version은 스토어의 버전을 말한다.
- valueFromReducer는 atom에서 get 수행 시 반환하는 값이다.
- atomFromReducer는 atom 그자체이다.
- atom 값은 훅 내부의 store에서 WeakMap 방식으로 별도의 키 없이 값을 저장한다.
- rerenderIfChanged는 넘겨받은 atom이 Reducer를 통해 스토어 atom과 달라지는 경우, subscribe를 수행하는 값이 변경된 경우 리렌더링을 유발한다.
useAtom
- useState와 동일한 형태로 첫번째 값은 useAtomValue 훅의 결과를 반환하고, 두번째 값은 useSetAtom 훅을 반환하며 atom 수정이 가능하다.
- setAtom에서 사용하는 write 함수에서는 스토어에서 해당 atom을 찾아 직접 값을 업데이트 한다.
Zustand
- 하나의 스토어를 중앙 집중형으로 활용해 스토어 내부에서 상태를 관리한다.
- 내부의 partial, replace로 state의 일부분 또는 전체를 변경할 수 있다.
- useStore에서는 useSyncExternelStoreWithSelector를 사용한다. useSyncExternalStore와 다른 점은 원하는 값을 가져올 수 있는 selector와 동등 비교를 할 수 있는 equalityFn 함수를 받는다는 것이다.
- create 내에 반환값, getter, setter 함수를 작성한다.
'React-study > presentation' 카테고리의 다른 글
[9장 발표] 모던 리액트 개발 도구로 개발 및 배포 환경 구축하기 (1) | 2024.12.08 |
---|---|
[발표] useTransition과 useDeferredValue (0) | 2024.06.25 |
[발표] useMemo와 useCallback (0) | 2024.06.18 |
[발표] useEffect와 useLayoutEffect (0) | 2024.06.04 |
[발표] useReducer / useContext / useRef (0) | 2024.05.30 |