벌써 세 번째 쓰는 전역상태관리 관련 블로그!
플레이어 프로젝트를 어떻게 하면 컴포넌트화 해서 재활용을 쉽게 할 수 있을까란 고민을 하던 중, 플레이어 자체를 전역에 두고 빼서 쓰면 프로젝트 내에서 상태 공유도 쉽고 하나의 모듈처럼 옮겨다닐 수 있지 않을까란 생각이 들었다.
전역상태관리 라이브러리를 고민하던 중, Context API와 Recoil이 후보가 됐다.
(Redux, Mobx 등등 많은데 다들 사용법이 너무 복잡해서 후보에 조차 올리지 않았다..^^)
실질적으로 Context API가 Recoil 보다도 사용성이 더 쉬우니 Context API에 대해서 더 자세히 조사해봤는데, 결과적으로 Recoil을 채택했다.
두 라이브러리를 비교한 과정을 살펴보고, Recoil 활용 심화버전(?)에 대해 살펴보자!
Context API는 react에 내장된 기능으로, props를 사용하지 않아도 특정 값이 필요한 컴포넌트끼리 쉽게 값을 공유할 수 있게 해준다.
createContext
함수를 불러와서 context를 생성한다.
import {createContext} from 'react';
const MyContext = creatreContext();
공유하고자 하는 state가 있는 컴포넌트를
context
내부Provider
를 꺼내 감싸주고, 공유하고자 하는 state를value
에 넣고 공유한다.
function App() {
return (
<MyContext.Provider value="Hello World">
<Children />
</MyContext.Provider>
);
}
value
에서 공유된 state를 활용하고 싶은 컴포넌트 안에서useContext
를 선언하여 state를 꺼내 쓰면 된다.
import {useContext} from 'react';
function Message() {
const value = useContext(MyContext);
return <div>Received: {value}</div>;
}
Context API는 Provider 하위의 모든 consumer들이 Provider 속성이 변경될 때마다 다시 렌더링된다.
만약 Provider의 값이 배열이나 객체인 경우, 구조가 조금이라도 변경된다면 그 Context를 구독하고 있는 하위의 모든 것들이 다시 렌더링된다.
새로운 Provider를 추가하면 되지 않을까?란 생각을 할 수 있으나, Provider를 추가하면 하위의 모든 것이 다시 마운트된다.
결국 tree 구조의 단점인 props drilling을 해결하기 위해 Context API를 쓰는 것이지만, Context API가 tree 구조에 추가되는 것밖에 되지 않는다.
Recoil은 React를 위한 상태관리 라이브러리로, atoms
(공유 상태)에서 selectors
(순수 함수)를 거쳐 React 컴포넌트로 내려가는 data-flow graph를 만들 수 있다.
Atom
은 react의 state
, props
와 비슷하지만 리덕스 store의 상태들처럼 구독(subscribe)할 수 있고 Atom
의 상태가 변경되면 구독하고 있는 컴포넌트들이 다시 렌더링되면서 변경된 Atom
의 상태를 공유한다.
변경된 상태가 자동으로 전역 공유가 된다.
Recoil은 변경된 Atom
의 상태를 공유하고 있는 컴포넌트만 리렌더링 되므로, Context API처럼 쓸모없는 리렌더링이 계속 일어나지 않는다.
atom
은 하나의 상태로, 컴포넌트가 구독할 수 있는 react state라고 생각하면 된다.
atom
의 값을 변경하면, 그것을 구독하고 있는 컴포넌트들이 모두 다시 렌더링된다.
atom
을 생성하기 위해 어플리케이션에서 고유한 key 값과 default값을 설정해야 한다.
export const nameState = atom({
key: 'nameState',
default: 'Jane Doe'
});
useRecoilState
:atom
의 값을 구독하여 업데이트할 수 있는 hook으로, useState와 동일한 방식으로 사용할 수 있다.useRecoilValue
: setter 함수 없이atom
의 값을 반환만 한다.useSetRecoilState
: setter 함수만 반환한다.
import {nameState} from './store/nameState.js'
// useRecoilState
const NameInput = () => {
const [name, setName] = useRecoilState(nameState);
const onChange = (event) => {
setName(event.target.value);
};
return (
<>
<input type="text" value={name} onChange={onChange} />
<div>Name : {name}</div>
</>
);
}
// useRecoilValue
const SomeOtherComponentWithName = () => {
const name = useRecoilValue(nameState);
return <div>{name}</div>;
}
// useSetRecoilState
const SomeOtherComponentThatSetsName = () => {
const setName = useSetRecoilState(nameState);
return <button onClick={() => setName('Jon Doe')}>Set Name</button>;
}
selector
는 state에서 파생된 데이터로, 다른atom
에 의존하는 동적인 데이터를 만들 수 있게 해준다.
Recoil의selector
는 get 함수를 갖고 있으며, 하나 이상의atom
을 업데이트할 수 있는 set 함수를 옵션으로 받을 수 있다.
// 동물 목록 상태
const animalsState = atom({
key : 'animalsState',
default :
[
{
name: 'Rexy',
type: 'Dog'
},
{
name: 'Oscar',
type : 'Cat'
}
],
});
// 필터링 동물 상태
const animalFilterState = atom({
key : 'animalFilterState',
default: 'dog',
});
// 파생된 동물 필터링 목록
const filteredAnimalsState = selector({
key : 'animalListState',
get : ({get}) => {
const filter = get(animalFilterState);
const animals = get(animalsState);
return animals.filter(animal => animal.type === filter);
}
});
// 필터링된 동물 목록을 사용하는 컴포넌트
const Animals = () => {
const animals = useRecoilValue(filterAnimalsState);
return animals.map(animal => (<div>{animal.name}, {animal.type}</div>));
}
요구사항
- 이미지를 동적으로 추가할 수 있어야한다.
- 이미지의 이름을 변경할 때 선택한 이미지와 메타 데이터 컴포넌트만 다시 렌더링해야 한다.
- 이미지와 데이터는 비동기적으로 로딩되어야 한다.
atomFamily
는 atom
과 동일하지만, 다른 인스턴스와 구분이 가능한 매개변수를 받을 수 있다.
아래 두 코드는 같은 의미이다.
atomFamily
는 내부적으로 memoization을 하기 때문에, 각 인스턴스마다 고유한 키를 만들 필요가 없다.
// atom
const itemWithId = memoize(id => atom({
key : `item-${id}`,
default : ...
}))
// atomFamily
const itemWithId = atomFamily({
key : 'item',
default : ...
});
atom
과 atomFamily
는 default값을 만들기 위해 다른 함수를 호출할 수 있다.
atomFamily
에서는 내부적으로 생성한 고유한 id를 넘겨줄 수 있다.
컴포넌트가 imageState를 최초로 호출할 때 default값을 만드는 함수 getImage(id)가 호출될 것이다.
const getImage = async (id) => {
return new Promise(resolve => {
const url = `http://someplace.com/${id}.png`;
let image = new Image();
image.onload = () => {
resolve({
id,
name : `Image ${id}`,
url,
metadata : {
width : `${image.width}px`,
height : `${image.height}px`
}
});
image.src = url;
});
};
export const imageState = atomFaily({
key : 'imageState',
default : async id => getImage(id)
});
// 이미지 목록
const Images = () => {
const imageList = useRecoilValue(imageListState);
return (
<div className="images">
{imageList.map(id => (
<Suspense key={id} fallback="Loading...">
<Image id={id} />
</Suspense>
))}
</div>
);
};
// 단일 이미지
const Image = ({id}) => {
const {name, url} = useRecoilValue(imageState(id));
return (
<div className="image">
<div className="name">{name}</div>
<img src={url} alt={name} />
</div>
);
};
selector
는 atom
으로부터 계산된 값을 얻을 수 있고, 복수의 atom
에게 영향을 줄 수도 있다.
selector
는 파생된 상태를 반환하며, setter 함수는 atomFamily
로 생성된 모든 친구들에게 영향을 줄 수 있으며 값을 재설정할 수 있다.
const colorCounterState = selector({
key: "colorCounterState",
get: ({ get }) => {
let counter = { [COLORS.RED]: 0, [COLORS.BLUE]: 0, [COLORS.WHITE]: 0 };
for (let i = 0; i < BOX_NUM; i++) {
const box = get(boxState(i));
counter[box] = counter[box] + 1;
}
return counter;
},
set: ({ set }) => {
for (let i = 0; i < BOX_NUM; i++) {
set(boxState(i), COLORS.WHITE);
}
}
});