리액트와 상태 관리 라이브러리 (1)
모던 리액트 Deep Dive: 리액트의 핵심 개념과 동작 원리부터 Next.js까지, 리액트의 모든 것 - 05장 리액트와 상태 관리 라이브러리 (1)
1. 상태 관리는 왜 필요한가?
상태는 어떠한 의미를 지닌 값이며 애플리케이션의 시나리오에 따라 지속적으로 변경될 수 있는 값을 의미한다.
UI
: 기본적으로 웹 애플리케이션에서 상태라 함은 상호 작용이 가능한 모든 요소의 현재의 값을 의미한다.URL
: 브라우저에서 관리되고 있는 상태값으로, 여기에도 우리가 참고할 만한 상태가 존재할 수 있다.폼(form)
: 폼에도 상태가 존재한다.서버에서 가져온 값
: 클라이언트에서 서버로 요청을 통해 가져온 값도 상태로 볼 수 있다.
상태 관리 자체는 크게 어려운 일이 아니며 단순히 손이 많이 가는 문제일 수도 있다.
- 상태를 어디에 둘 것인가?
- 전역 변수에 둘 것인가?
- 별도의 클로저를 만들 것인가?
- 그 상태의 유효범위는 어떻게 제한할 수 있을 까?
- 상태의 변화에 따라 변경돼야 하는 자식 요소들은 어떻게 이 상태의 변화를 감지할 것인가?
- 이러한 상태 변화가 일어남에 따라 즉각적으로 모든 요소들이 변경되어 애플리케이션이 찢어지는 현상을 어떻게 방지할 것인가?
리액트 상태 관리의 역사
리액트는 단순히 사용자 인터페이스를 만들기 위한 라이브러리일 뿐이고, 그 이상의 기능을 제공하지 않고 있다.
Flux 패턴의 등장
페이스북 팀은 단방향으로 데이터 흐름을 변경하는 것을 제안하는데 이것이 바로 Flux
패턴의 시작이다.
액션(action)
: 어떠한 작업을 처리할 액션과 그 액션 발상 시 함께 포함시킬 데이터를 의미한다.디스패처(dispatcher)
: 액션을 스토어에 보내는 역할을 한다.스토어(store)
: 여기에서 실제 상태에 따른 값과 상태를 변경할 수 있는 메서드를 가지고 있다.뷰(View)
: 리액트의 컴포넌트에 해당하는 부분으로, 스토어에서 만들어진 데이터를 가져와 화면을 렌더링하는 역할을 한다. 뷰에서도 상태를 업데이터하고자 할 수 있다.
단방향 데이터 흐름 방식은 당연히 불편함도 존재한다.
- 사용자 입력에 따라 데이터 갱신하고 화면을 어떻게 업데이트해야 하는지도 코드로 작성해야 하므로 코드의 양이 많아지고 개발자도 수고로워진다.
시장 지배자 리덕스의 등장
리액트와 단방향 데이터 흐름이 점점 두각을 드러내던 와중에 등장하는 것이 바로 리덕스다.
리덕스 또한 최초에는 이 Flux
구조를 구현하기 위해 만들어진 라이브러리 중 하나이며 Elm
아키텍처를 도입했다.
Elm
은 웹페이지를 선언적으로 작성하기 위한 언어다.
Elm
코드에서 주목할 것은 model
, update
, view
이며 핵심이다.
모델(model)
: 애플리케이션의 상태를 의미한다.뷰(View)
: 모델을 표현하는 HTML을 말한다.업데이트(update)
: 모델을 수정하는 방식을 말한다.
Elm
은 Flux
와 마찬가지로 데이터 흐름을 세 가지로 구분한다.
리덕스는 하나의 상태 객체를 스토어에 저장해 두고, 이 객체를 업데이터하는 작업을 디스패치해 업데이트를 수행한다.
prop
내려주기 문제를 해결할 수 있었고, 스토어가 필요한 컴포넌트라면 단지 connect
만 쓰면 스토어에 바로 접근할 있었다.
- 하지만 `redux`는 하고자 하는 일에 비에 보일러플레이트가 너무 많았다.
Context API와 useContext
props
로 상태를 넘겨주지 않더라도 Context API
를 사용하면 원하는 곳에서 주입하는 상태를 사용할 수 있게 됬다.
Context API
는 상태 관리가 아닌 주입을 도와주는 기능이며, 렌더링을 막아주는 기능 또한 존재하지 않으니 사용할 때 주의가 필요하다.
훅의 탄생, 그리고 React Query와 SWR
16.8 버전에서 함수형 컴포넌트에 사용할 수 있는 다양한 훅 API를 추가했다.
function useCounter() {
const [count, setCount] = useState(0);
function increase() {
setCount((prev) => prev + 1);
}
return { count, increase };
}
내부적으로 관리하고 있는 state
도 있으며, 또 이를 필요한 곳에서 재사용할 수도 있게 됐다.
새로운 상태 관리가 등자하는데 바로 React Query
와 SWR
이다.
두 라이브러리는 모두 외부에서 데이터를 불러오는 fetch
를 관리하는데 특화된 라이브러리지만, API
호출에 대한 상태를 관리하고 있기 때문에 HTTP
요청에 특화된 상태 관리 라이브러리다.
import React from 'react'
import useSwR from 'swr'
const fetcher = (url) => fetch(url).then((res)=> res.json())
export default function App() {
const { data, error } = useSMR(
"https://api.github.com/repos/vercel/swr",
fetcher,
)
if (error) return 'An error has occurred'
if (!data) return 'Loading...'
return (
<div>
<p>{JSON.stringify(data)}K/p>
</div>
)
}
Recoil, Zustand, Jotai, Valtio에 이르기까지
페이스북 팀에서 만든 Recoil
을 필두로, Jotai
, Zustand
, Valtio
등 다양한 라이브러리가 선보이게 된다.
//Recoil
const counter = atom({ key: 'count', default: 0 });
const todolist = useRecoilValue(counter);
// Jotai
const countAtom = atom(0);
const [count, setCount] = useAtom(countAtom);
// zustand
const useCounterstore = create((set) => ({
count: 0,
increase: () => set((state) => ({ count: state.count + 1 })),
}));
const count = useCounterStore((state) => state.count);
// Valtio
const state = proxy({ count: 0 });
const snap = useSnapshot(state);
state.count++;
훅을 활용해 작은 크기의 상태를 효율적으로 관리한다.
2. 리액트 훅으로 시작하는 상태 관리
가장 기본적인 방법: useState와 useReducer
useState
의 등장으로 리액트에서는 여러 컴포넌트에 걸처 손쉽게 동일한 인터페이스의 상태를 생성하고 관리할 수 있게 됐다.
리액트의 훅을 기반으로 만든 사용자 정의 훅은 함수형 컴포넌트라면 어디서든 손쉽게 재사용이 가능하다는 장점이 있다.
실제로
useState
는useReducer
로 구현했다.- 이와 반대로,
useReducer
또한useState
로 작성할 수 있다.
- 이와 반대로,
useState
나 useReducer
모두 약간의 구현상의 차이만 있을 뿐, 두 훅 모두 지역 상태 관리를 위해 만들어졌다는 것을 알 수 있다.
- 이 둘은 상태 관리의 모든 필요성과 문제를 해결해 주지는 않는다.
- 기본적인
useState
를 기반으로 한 상태를 지역 상태(local state)라고 하며, 이 지역 상태는 해당 컴포넌트 내에서만 유효하다는 한계가 있다.
컴포넌트로 사용하는 모든 훅이 동일한 값을 참조할 수 있게 상태를 컴포넌트 밖으로 한 단계 끌어 올리는 것이다.
function Counter1({ counter, inc }: { counter: number, inc: () => void }) {
return (
<>
<h3>Counter1: {counter} </h3>
<button onclick={inc}>+</button>
</>
);
}
function Counter2({ counter, inc }: { counter: number, inc: () => void }) {
return (
<>
<h3>Counter2: {counter}</h3>
<button onClick={inc}>+</button>
</>
);
}
function Parent() {
const { counter, inc } = useCounter();
return (
<>
<counter1 counter={counter} inc={inc} />
<Counter2 counter={counter} inc={inc} />
</>
);
}
지역 상태의 한계를 벗어나보자: useState의 상태를 바깥으로 분리하기
현재 리액트의 useState
는 리액트가 만든 클로저 내부에서 관리되어 지역 상태로 생성되기 때문에 해당 컴포넌트에서만 사용할 수 있다는 단점이 있다.
함수형 컴포넌트에서 리렌더링을 하려면 다음과 같은 작업 이 일어나야 한다.
useState
,useReducer
의 반환값 중 두 번째 인수로 어떻게든 호출된다.- 부모 함수가 리렌더링되거나 해당 함수가 다시 실행돼야 한다.
const store = createStore({ count: 0, text: 'hi' });
function Counter() {
const counter = useStoreSelector(
store,
useCallback((state) => state.count, [])
);
function handleClick() {
store.set((prev) => ({ ...prev, count: prev.count + 1 }));
}
useEffect(() => {
console.log('Counter Rendered');
});
return (
<>
<h3>{counter}</h3>
<button onClick={handleClick}>+</button>
</>
);
}
const textSelector = (state) => state.text;
function TextEditor() {
const text = useStoreSelector(store, textSelector);
useEffect(() => {
console.log('Counter Rendered');
});
function handleChange(e) {
store.set((prev) => ({ ...prev, text: e.target.value }));
}
return (
<>
<h3>{text}</h3>
<input value={text} onChang={handleChange} />
</>
);
}
store
가 객체로 구성되어 있어도 컴포넌트에서 필요한 값만 select
해서 사용하고 수행할 것이다.
한 가지 주의할 점은 useStoreSelector
에 제공하는 두 번째 인수로 selector
를 컴포넌트 밖에 선언하거나, 이것이 불가능하다면 useCallback
을 사용해 참조를 고정시켜야 한다는 것이다.