[React.js] 상태 관리 라이브러리 비교 3 - Recoil
Recoil
호환성 및 단순함을 이유로 외부의 글로벌 상태관리 라이브러리보다는 React 자체에 내장된 상태 관리 기능을 사용하는 것이 좋으나 내장 상태 관리 기능은 다음과 같은 한계를 가진다.
- 컴포넌트의 상태는 공통된 상위요소까지 끌어올림으로써 공유될 수 있지만, 상위요소의 변경은 하위요소를 재렌더링 시키므로, 이 과정에서 거대한 트리가 다시 렌더링되는 효과를 일으키기도 한다.
- Context는 단일 값만 저장할 수 있으며, 자체 consumer를 가지는 여러 값들의 집합을 담을 수는 없다.
- 이 두 가지 특성이 트리의 최상단(state가 존재하는 곳)부터 트리의 잎(state가 사용되는 곳)까지의 코드 분할을 어렵게 한다.
Facebook의 Dave McCabe가 개발한 React용 상태 관리 라이브러리이다.
Recoil은 직교하지만 본질적인 방향 그래프를 정의하고 React 트리에 붙인다. 상태 변화는 이 그래프의 뿌리(atoms라고 부르는)로부터 순수함수(selectors라고 부르는 것)을 거쳐 컴포넌트로 흐르며, 다음과 같은 접근 방식을 따른다.
- 공유상태(shared state)도 React의 내부상태(local state)처럼 간단한 get/set 인터페이스로 사용할 수 있도록 보일러플레이트가 없는 API를 제공한다. (필요한 경우 reducers 등으로 캡슐화 할 수도 있다.)
- 동시성 모드(Concurrent Mode)를 비롯한 새로운 React의 기능들과의 호환 가능성도 갖는다.
- 동시성 모드란? : 흐름이 여러 개가 존재하는 경우를 의미하며, 리액트에서 알아서 렌더링 동작의 우선순위를 정하여 적절한 때에 렌더링을 해준다는 개념이다.
- 캐싱지원 : selector를 통해 비동기 작업을 진행할 때 사용되는 atom에 자동으로 의존성이 걸리게 된다. 즉, atom의 값이 변경될 때 마다 selector가 변경되는데, atom의 값이 같으면 내부적으로 반환값을 메모이즈 하고 있어 캐싱된 값을 반환하게 된다.
- 상태 정의는 증분 및 분산되므로 코드 분할이 가능하다.
- 상태를 사용하는 컴포넌트를 수정하지 않고도 상태를 파생된 데이터로 대체할 수 있다.
- 파생된 데이터를 사용하는 컴포넌트를 수정하지 않고도 파생된 데이터는 동기식과 비동기식 간에 이동할 수 있다.
- 탐색을 일급 개념으로 취급할 수 있고 심지어 링크에서 상태 전환을 인코딩할 수도 있다.
- 역호환성 방식으로 전체 애플리케이션 상태를 유지하는 것은 쉬우므로, 유지된 상태는 애플리케이션 변경에도 살아남을 수 있다.
주요 개념
개요
Recoil을 사용하면 atoms(공유 상태)에서 selectors(순수 함수)를 거쳐 React 컴포넌트로 내려가는 data-flow graph를 만들 수 있다. Atoms는 컴포넌트가 구독할 수 있는 상태의 단위다. Selectors는 atoms 상태값을 동기 또는 비동기 방식을 통해 변환한다.
Atoms
Atoms는 상태의 단위이며, 업데이트와 구독이 가능하다. atom이 업데이트되면 각각의 구독된 컴포넌트는 새로운 값을 반영하여 다시 렌더링된다. atoms는 런타임에서 생성될 수도 있다. Atoms는 React의 로컬 컴포넌트의 상태 대신 사용할 수 있다. 동일한 atom이 여러 컴포넌트에서 사용되는 경우 모든 컴포넌트는 상태를 공유한다.
Atoms는 atom 함수를 사용해 생성한다.
const fontSizeState = atom({
key: 'fontSizeState',
default: 14,
});
Atoms는 디버깅, 지속성 및 모든 atoms의 map을 볼 수 있는 특정 고급 API에 사용되는 고유한 키가 필요하다. 두 개의 atom이 같은 키를 갖는 것은 오류이기 때문에 키 값은 전역적으로 고유하도록 해야한다. React 컴포넌트의 상태처럼 기본값도 가진다.
컴포넌트에서 atom을 읽고 쓰려면 useRecoilState라는 훅을 사용한다. useState와 비슷하지만 상태가 컴포넌트 간에 공유될 수 있다는 차이가 있다.
function FontButton() {
const [fontSize, setFontSize] = useRecoilState(fontSizeState);
return (
<button onClick={() => setFontSize((size) => size + 1)} style={{fontSize}}>
Click to Enlarge
</button>
);
}
버튼을 클릭하면 버튼의 글꼴 크기가 1만큼 증가하며, fontSizeState atom을 사용하는 다른 컴포넌트의 글꼴 크기도 같이 변경된다.
function Text() {
const [fontSize, setFontSize] = useRecoilState(fontSizeState);
return <p style={{fontSize}}>This text will increase in size too.</p>;
}
Selectors
Selector는 atoms이나 다른 selectors를 입력으로 받아들이는 순수함수(pure function)이다. 상위의 atoms 또는 selectors가 업데이트되면 하위의 selector함수도 다시 실행된다. 컴포넌트들은 selectors를 atoms처럼 구독할 수 있으며 selectors가 변경되면 컴포넌트들도 다시 렌더링된다.
Selectors는 상태를 기반으로 하는 파생 데이터를 계산하는데 사용된다. 최소한의 상태 집합만 atoms에 저장하고 다른 모든 파생되는 데이터는 selectors에 명시한 함수를 통해 효율적으로 계산함으로써 쓸모없는 상태의 보존을 방지한다.
Selectors는 어떤 컴포넌트가 자신을 필요로하는지, 또 자신은 어떤 상태에 의존하는지를 추적하기 때문에 이러한 함수적인 접근 방식을 매우효율적으로 만든다.
컴포넌트의 관점에서 보면 selectors와 atoms는 동일한 인터페이스를 가지므로 서로 대체할 수 있다.
Selectors는 selector함수를 사용해 정의한다.
const fontSizeLabelState = selector({
key: 'fontSizeLabelState',
get: ({get}) => {
const fontSize = get(fontSizeState);
const unit = 'px';
return `${fontSize}${unit}`;
},
});
get 속성은 계산될 함수다. 전달되는 get 인자를 통해 atoms와 다른 selectors에 접근할 수 있다. 다른 atoms나 selectors에 접근하면 자동으로 종속 관계가 생성되므로, 참조했던 다른 atoms나 selectors가 업데이트되면 이 함수도 다시 실행된다.
이 fontSizeLabelState 예시에서 selector는 fontSizeState라는 하나의 atom에 의존성을 갖는다. 개념적으로 fontSizeLabelState selector는 fontSizeState를 입력으로 사용하고 형식화된 글꼴 크기 레이블을 출력으로 반환하는 순수 함수처럼 동작한다.
Selectors는 useRecoilValue()를 사용해 읽을 수 있다. useRecoilValue()는 하나의 atom이나 selector를 인자로 받아 대응하는 값을 반환한다. fontSizeLabelState selector는 writable하지 않기 때문에 useRecoilState()를 이용하지 않는다. (writable한 selector API reference에 자세히 기술되어 있다.)
function FontButton() {
const [fontSize, setFontSize] = useRecoilState(fontSizeState);
const fontSizeLabel = useRecoilValue(fontSizeLabelState);
return (
<>
<div>Current font size: ${fontSizeLabel}</div>
<button onClick={setFontSize(fontSize + 1)} style={{fontSize}}>
Click to Enlarge
</button>
</>
);
}
버튼을 클릭하면 버튼의 글꼴 크기가 증가하는 동시에 현재 글꼴 크가를 반영하도록 글꼴 크기 레이블을 업데이트 하는 두 가지 작업이 수행된다.
설치
npm install recoil
yarn add recoil
Bundler
Recoil은 Webpack 또는 Rollup과 같은 모듈 번들러와도 문제없이 호환된다.
- Webpack : 웹에서 사용되는 모든 자원(assets)을 각각의 모듈로 보고 이를 하나의 결과물(js 파일)로 만드는 모듈 번들러이다.
- Rollup : 작은 코드 조각을 라이브러리나 애플리케이션과 같이 더 크고 복잡한 것으로 컴파일하는 JavaScript용 모듈 번들러이다.
ES5 지원X
Recoil 빌드는 ES5로 트랜스파일 되지 않으므로, Recoil을 ES5와 사용하는 것은 지원하지 않는다. ES6 기능을 natively하게 제공하지 않는 브라우저를 지원해야하는 경우 Babel로 코드를 컴파일하고 preset @babel/preset-env를 이용하여 이를 수행할 수는 있지만 문제가 발생할 수도 있다.
- Babel : 최신 버전의 자바스크립트 문법을 이해하지 못하는 브라우저에서도 브라우저가 이해할 수 있는 문법으로 변환해주는 역할을 하는 자바스크립트 컴파일러이다.
- @babel/preset-env : 대상 환경에 필요한 구문 변환(및 선택적으로 브라우저 폴리필)을 세세하게 관리할 필요없이 최신 JavaScript를 사용할 수 있는 사전 설정이다.
특히, React와 같이, Recoil은 ES6의 Map과 Set 타입에 의존하는데, 이러한 ES6의 요소들을 polyfills를 통해 에뮬레이션하는 것은 성능상의 문제를 야기할 수 있다.
CDN
버전 0.0.11 이후, Recoil은 <script> 태그에 직접 사용될 수 있는 UMD 빌드를 제공하여 Recoil 심볼을 글로벌 네임스페이스에 노출시킨다. 최신 버전으로부터 예기치 않은 손상을 방지하기 위해 안정된 특정 버전 번호 및 빌드에 연결시키는 것이 좋다.
<script src="https://cdn.jsdelivr.net/npm/recoil@0.0.11/umd/recoil.production.js"></script>
ESLint
프로젝트에서 eslint-plugin-react-hooks을 사용하는 경우, 예를 들어 다음과 같이 eslint 설정을 사용하는 경우
// 이전의 .eslint 설정
{
"plugins": ["react-hooks"],
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}
'useRecoilCallback'을 additionalHooks 목록에 추가하는 것이 좋다. 이를 추가하면, ESLint는 useRecoilCallback()을 사용하기 위해 전달된 종속성이 잘못 지정되었을 때 경고를 표시하고 해결 방안을 제시한다. additionalHooks의 형식은 정규식(regex) 문자열이다.
// 수정된 .eslint 설정
{
"plugins": ["react-hooks"],
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": [
"warn",
{
"additionalHooks": "useRecoilCallback"
}
]
}
}
장점
- React 비동기 처리를 위한 상태관리 라이브러리로, hooks을 이용하기 때문에 문법 자체가 리액트스럽다.
- Redux와 비교해 보일러플레이트가 많지 않으며, 러닝커브가 낮다.
단점
- 아직 안정화 단계에 있지 않다.
- 공식 문서의 예시가 부족하다는 의견이 있다.
Reference