처음으로
react

01. 리액트 훅을 이용한 마이크로 상태 관리

다이시 카토의 리액트 훅을 활용한 마이크로 상태 관리 1부

2024-04-15

마이크로 상태 관리 이해하기

마이크로 상태 관리 (리액트의 가벼운 상태 관리)

  • 범용적인 상태 관리를 위한 방법은 가벼워야 합니다.
  • 개발자는 요구사항에 따라 적절한 방법을 선택할 수 있어야 합니다.

리액트 훅 사용하기

리액트 훅에는 상태 관리 방법을 구현하기 위한 몇 가지 기본 리액트 훅이 포함돼 있습니다.

  • useState
  • useReducer
  • useEffect

리액트 훅이 참신한 이유는 UI 컴포넌트에서 로직을 추출할 수 있기 때문입니다.

const useCount = () => {
	const [count, setCount] = useState(0);
	return [count, setCount];
};

const Component = () => {
	const [count, setCount] = useCount();
	
	return (
		<div>
			{count}
			<button onClick={() => setCount((c) => c + 1)}>
				+1
			</button>
		</div>
	);
};

위 코드를 보면 불필요하게 복잡해졌다고 생각할 수 있지만 다음 두 가지 관점을 생각해볼 수 있습니다.

  • useCount라는 이름을 통해 더 명확해졌다.
    • 일반적으로 프로그래밍에서 매우 중요한 점으로 이름을 적절하게 지정하면 코드의 가독성이 더 좋아집니다.
  • Component가 useCount 구현과 분리됐다.
    • useCount가 Component에서 분리됐으므로 컴포넌트를 건드리지 않고도 기능을 추가할 수 있습니다.

데이터 불러오기를 위한 서스펜스와 동시성 렌더링

리액트 훅 함수와 컴포넌트 함수는 여러번 호출될 수 있기 때문에, 여러번 호출되더라도 일관되게 동작할 수 있도록 순수함을 보장해야 합니다.

전역 상태 탐구하기

리액트에서 전역 상태를 구현하는 것은 간단한 작업이 아닙니다. 그 이유는 리액트가 컴포넌트 모델에 기반하기 때문입니다. 컴포넌트 모델에서는 지역성(locality)이 중요하며, 이는 컴포넌트가 서로 격리돼야 하고 재사용이 가능해야 한다는 것을 의미합니다.

책에서는 팁으로 다음과 같은 내용을 설명합니다.

💡 컴포넌트는 함수처럼 재사용 가능한 하나의 단위다. 컴포넌트를 한 번 정의하면 여러 번 사용하는 것이 가능하다. 이는 컴포넌트가 독립적인 경우에만 가능하다. 컴포넌트가 컴포넌트 외부에 의존하는 경우 동작이 일관되지 않을 수 있으므로 재사용이 불가능할 수 있다. 따라서 엄밀하게 말하면 컴포넌트 자체는 전역 상태에 가급적 의존하지 않는 것이 좋다.

useState 사용하기

값으로 상태 갱신하기

useState로 상태 값을 갱신하는한 가지 방법은 새로운 값을 제공하는 것입니다.

const Component = () => {
	const [count, setCount] = useState(0);
		
	return (
		<div>
			{count}
			<button onClick={() => setCount(1)}>
				Set Count to 1
			</button>
		</div>
	);
};

간단한 코드 예제입니다. 버튼을 클릭하면 Set Count to 1로 리렌더링됩니다. 이때 버튼을 한 번 더 클릭한다면 어떻게 될까요? setCount(1)을 다시 호출하지만 동일한 값이기 때문에 베일아웃되어 컴포넌트가 리렌더링되지 않습니다.

const Component = () => {
	const [count, setCount] = useState(0);
		
	return (
		<div>
			{count}
			<button onClick={() => setCount(count + 1)}>
				Set Count to {count + 1}
			</button>
		</div>
	);
};

위 예제에서는 버튼을 클릭하면 카운트가 증가합니다. 하지만 버튼을 빠르게 두 번 클릭해도 한 번만 증가합니다. 이는 “리액트의 상태는 스냅샷이다”와 관련이 있습니다. 여튼 실제로 버튼을 클릭한 횟수를 세야 한다면 갱신 함수가 필요합니다.

함수로 상태 갱신하기

useState로 상태를 갱신하는 또 다른 방법은 갱신 함수를 사용하는 것입니다.

const Component = () => {
	const [count, setCount] = useState(0);
		
	return (
		<div>
			{count}
			<button onClick={(c) => setCount(c + 1)}>
				Increment Count
			</button>
		</div>
	);
};

위와 같이 코드를 작성하면실제로 버튼을 클릭한 횟수를 셀 수 있습니다. 하지만 갱신 함수를 사용하더라도 이전 상태와 정확히 동일한 상태를 반환한다면 베일아웃이 발생하고 컴포넌트는 리렌더링되지 않습니다.

지연 초기화

useState는 첫 번째 렌더링에서만 평가되는 초기화 함수를 받을 수 있습니다.

const init = () => 0;

const Component = () => {
	const [count, setCount] = useState(init);
		
	return (
		<div>
			{count}
			<button onClick={(c) => setCount(c + 1)}>
				Increment Count
			</button>
		</div>
	);
};

물론 위 예제에서 사용한 init 함수는 무거운 계산을 포함하는 함수가 아니기 때문에 크게 효과가 있지는 않지만, 만약 init 함수가 무거운 계산을 포함하는 함수라면 useState가 호출되기 전까지 평가되지 않고 느리게 평가되므로 성능에 도움을 받을 수 있습니다. 즉 지연 초기화를 사용하면 컴포넌트가 마운트될 때 한 번만 호출되어 CPU가 무거운 계산을 한 번만 해도 됩니다.

useReducer 사용하기

기본 사용법

리듀서는 복잡한 상태에 유용합니다.

const reducer = (state, action) => {
	switch (action.type) {
		case 'INCREMENT':
			return { ...state, count: state.count + 1 };
		case 'SET_TEXT':
			return { ...state, text: action.text };
		default:
			throw new Error('unknown action type');
	}
};

const Component = () => {
	const [state, dispatch] = useReducer(reducer, { count: 0, text: 'hi' });
	...

위 예제 코드는 두 개의 속성을 가진 객체가 있는 간단한 예제입니다. useReducer를 사용하면 이처럼 미리 정의된 리듀서 함수와 초기 상태를 매개 변수로 받아 리듀서를 정의할 수 있습니다.

예제 코드에선 훅 외부에서 리듀서를 작성하였는데, 이렇게 하면 코드를 분리할 수 있고 이에 따라서 테스트하기에 용이합니다. 리듀서 함수는 순수 함수이기 때문에 테스트하기가 더욱 쉽습니다.

베일아웃

useState에 대해 공부하면서 잠깐 등장했던 베일아웃은 useReducer에서도 작동합니다.

const reducer = (state, action) => {
	switch (action.type) {
		case 'INCREMENT':
			return { ...state, count: state.count + 1 };
		case 'SET_TEXT':
			if (!action.text) {
				// 베일아웃
				return state;
			}
			return { ...state, text: action.text };
		default:
			throw new Error('unknown action type');
	}
};

위 예시 코드는 action.text가 비어 있을 때 베일아웃되도록 하는 코드입니다. 이때 중요한 점은 state 자체를 반환해야한다는 점입니다. { ...state, text: aciton.text || state.text } 를 반환하게 되면 결국 새로운 객체를 반환하는 꼴이기 때문에 베일아웃이 발생하지 않습니다.

원시 값

지금까지 useReducer에 대한 내용을 읽을 때에는 객체에 대해서만 설명했지만 사실 원시 값에 대해서도 작동합니다.

const reducer = (count, delta) => {
	if (delta < 0) {
		throw new Error('delta cannot be negative');
	}
	if (delta > 10) {
		// 너무 크다면 무시
		return count;
	}
	if (count < 100) {
		// 보너스를 더한다
		return count + delta + 10;
	}
	return count + delta;
}

위 리듀서 함수는 상태 값은 단순한 숫자이지만, 안에서 여러 복잡한 로직을 작성하는데 꽤나 유용해 보입니다.

지연 초기화(init)

useReducer는 지연 초기화를 위해 init이라는 세 번째 선택적인 매개변수를 받을 수 있습니다.

const init = (count) => ({ count, text: 'hi' });

...

const Component = () => {
	const [state, dispatch] = useReducer(reducer, 0, init); // 리듀서, 초깃값, 지연 초기화
	...

useState 때와 마찬가지로 지연 초기화를 통해 초깃값을 넘기면 컴포넌트가 마운트될 때 한 번만 호출되므로 무거운 연산을 포함할 수 있습니다.

useState와 useReducer의 유사점과 차이점

useReducer를 이용한 useState 구현

useReducer로 useState를 구현하는 것은 100% 가능하며, 실제로 리액트 내부에서 useState는 useReducer로 구현돼 있습니다.

const reducer = (prev, action) =>
	typeof action === 'function' ? action(prev) : action;
	
const useState = (initialState) =>
	useReducer(reducer, initialState);

useState를 이용한 useReducer 구현

useState로 useReducer를 구현하는 것은 거의 가능합니다.

const useReducer = (reducer, initialArg, init) => {
	const [state, setState] = useState(
		init ? () => init(initialArg) : initialArg,
	);
	const dispatch = useCallback(
		(action) => setState(prev => reducer(prev, aciton)),
		[reducer]
	);
	return [state, dispatch];
};

위 예제 코드는 useReducer의 대부분의 기능이 거의 완벽하게 작동합니다. 하지만 두 가지 미묘한 차이점이 있는데, 다음 두 하위 절에 그 내용이 적혀 있습니다.

초기화 함수 사용하기

const init = (count) => ({ count });
const reducer = (prev, delta) => ({ ...prev, count: prev.count + delta });

const ComponentWithUseReducer = ({ initialCount }) => {
	const [state, dispatch] = useReducer(reducer, initialCount, init);
	...
};

const ComponentWithUseState = ({ initialCount }) => {
	const [state, setState] = useState(() => init(initialCount));
	const dispatch = (delta) => setState((prev) => reducer(prev, delta));
	...
};

위 예제 코드를 보면 useState에는 두 개의 인라인 함수가 필요하지만 useReducer에는 인라인 함수가 없습니다. 사소한 차이점으로 보이지만 일부 인터프리터나 컴파일러는 인라인 함수 없이도 최적화가 더 잘 될 수 있습니다.

인라인 리듀서 사용하기

💡 이 기능은 일반적으로 사용되지 않으며 꼭 필요한 경우가 아니라면 권장하지 않는다.

인라인 리듀서 함수는 외부 변수에 의존할 수 있습니다. 이는 useReducer만의 특별한 기능입니다.

const useScore = (bonus) =>
	useReducer((prev, delta) => prev + delta + bonus, 0);

위 코드는 bonus와 delta가 모두 갱신된 경우에도 올바르게 동작합니다. 여기서 동작이란 예를 들어 useBonus에 주입하는 bonus 값을 변경한 후 바로 dispatch를 실행하는 경우를 말합니다.