"recoil": "^0.7.7"
Redux에 이어 Recoil 을 공부하고 프로젝트에 적용해 보고자 한다.
Redux를 공부할 때 간단히 작성해보았던 상태관리 라이브러리의 발전 과정이다.
useState에 익숙한 분들이라면 Recoil 시작하기 만 보고 금방 사용할 수 있을 것 이다.
recoil은 리액트에서 사용하기 위한 상태관리 라이브러리이므로 우선 리액트 앱을 만들어준다.
npx create-react-app recoil-example-app --template typescript
또는
npm create vite@lastes -- --template react-ts
npm install recoil
src 폴더 안 index.tsx 또는 App.tsx에 추가하면 되는데,
개인적으로 App.tsx에 프로젝트 세팅 관련된 설정을 모두 추가하는 것을 선호하는 편이다.
import { RecoilRoot } from 'recoil';
function App() {
return (
<RecoilRoot>
<Container />
</RecoilRoot>
);
}
return App;
atom의 기본적인 사용법은 리액트의 기본 hook인 useState와 사용 방식이 비슷하다.
react의 useState 대신 recoil의 useRecoilState를 사용하고,
초기값을 넣어주는 대신에 key와 default 값이 담긴 atom을 생성하고 그 값을 초기값에 넣어주면 끝이다!
store/index.ts
import { atom } from 'recoil';
const textState = atom({
key: 'textState', // unique ID (with respect to other atoms/selectors)
default: '', // default value (aka initial value)
});
Container.tsx
import { useRecoilState } from 'recoil';
import { textState } from '@store/Container';
function Container() {
const [text, setText] = useRecoilState(textState);
const onChange = (event) => {
setText(event.target.value);
};
return (
<div>
<input type="text" value={text} onChange={onChange} />
<br />
Echo: {text}
</div>
);
}
return Container;
4번까지의 내용을 보면 상태가 전역적으로 관리되며, 상태를 가져오고 변경하는 방식은 useState와 흡사하다.
여기서 selector라는 개념이 등장한다.
Selector는 파생된 상태(derived state)의 일부를 나타낸다. 파생된 상태는 상태의 변화다. 파생된 상태를 어떤 방법으로든 주어진 상태를 수정하는 순수 함수에 전달된 상태의 결과물로 생각할 수 있다.
store/index.ts
import { atom } from 'recoil';
const textState = atom({ ... 중략 ... });
const charCountState = selector({
key: 'charCountState', // unique ID (with respect to other atoms/selectors)
get: ({ get }) => {
const text = get(textState);
return text.length;
},
});
CharacterCount.tsx
import { useRecoilValue } from 'recoil';
import { textState } from '@store/Container';
function CharacterCount() {
const count = useRecoilValue(charCountState);
return <>Character Count: {count}</>;
}
return CharacterCount;
쉽게 이해되던 atom 사용법과 다르게 select는 개념과 사용법을 이해하는데 헤맸고, 그러던 중 recoil의 atom과 select를 비교한 좋은 글을 발견했다.
위 글을 요약하자면 다음과 같다.
즉 “ atom 을 원하는 대로 변형해 값을 리턴받는다. ” 라고 생각할 수 있겠습니다. 이 과정을 마치 데이터베이스에서 저장된 데이터를 Select 을 통해 원하는 결과를 뽑아오는 과정으로도 유추 해볼 수 있겠다 라는 생각을 하였고, 이를 Select 과 연결하여 추상화 하니 이해하기 편했습니다.
또한 selector 은 readonly 한 값 만을 반환합니다. 따라서 Recoil 을 활용할 때 수정 가능한 값을 반환 받고자 한다면 반드시 atom 을 활용해야 합니다.
다음 내용은 recoil을 적용한 프로젝트에 대해 코드리뷰를 받으며, atom과 select에 대해 여쭤봤을 때 받은 답변이다.
공식 문서 상에는 기본적인 상태는 atom으로 관리하고, 이를 기반으로 계산된 결과는 selector를 통해 관리하면 좋다고 나와 있네요.예를 들어 페이지 혹은 컴포넌트가 구독하는 상태 값이 리스트고 리스트 자체를 구독하면 atom을 만일 필더링 된 리스트 상태라면 selector를 사용하면 될 것 같습니다.
어렴풋이 이해를 하고 하나씩 적용해갔다.
다음 글은 atom effect를 적용할 때 참고했던 글이며, 로컬스토리지 외에도 서버데이터, 쿠키 등의 값으로 초기화 하는 방법에 대한 설명이 담겨있다.
Recoil with Storage (feat. effects)
store/Bookmark/index.ts
import { atom, AtomEffect } from "recoil";
import { IProductItemWithBookmark } from "@type/ProductList";
const localStorageEffect: <T>(key: string) => AtomEffect<T> = (key: string) => ({ setSelf, onSet }) => {
const savedValue = localStorage.getItem(key);
if (savedValue !== null) {
setSelf(JSON.parse(savedValue));
}
onSet((newValue, _, isReset) => {
isReset
? localStorage.removeItem(key)
: localStorage.setItem(key, JSON.stringify(newValue));
});
};
export const productItemWithBookmark = atom<IProductItemWithBookmark[]>({
key: "productItemWithBookmark",
default: [] as IProductItemWithBookmark[],
effects: [localStorageEffect("bookmarks")],
});
selector에서 set 함수를 사용하면 atom의 상태를 바꾸게 할 수 있다.
만약 atom에 넣을 데이터를 만드는 연산이 복잡할 경우 다음과 같은 단점이 있을 수 있다.
이럴 때 selector의 get 함수 또는 set 함수를 쓰면 좋다.
비동기 데이터 쿼리 - 데이터를 저장하지 않고 Selector get콜백에서 나온 값 그 자체 대신 프로미스를 리턴하면 인터페이스는 정확하게 그대로 유지
상품을 받아와 북마크 여부를 추가하는 복잡한 연산을 selector에서 처리하되, 저장하여 인터페이스에 영향을 주는 것이 아닌 연산한 값을 반환하여 깔끔하고 편안하게 데이터 연산을 할 수 있었다.
export const countParams = atom({
key: "countParams",
default: 10,
})
export const reqGetProductList = selector<IProductItemWithBookmark[]>({
key: "reqGetProductList",
get: ({ get }) => {
// 비동기 데이터 쿼리 - 데이터를 저장하지 않고 Selector get콜백에서 나온 값 그 자체 대신 프로미스를 리턴하면 인터페이스는 정확하게 그대로 유지
// 1. async/await로 API 호출
const response = await reqProductList({
count: get(countParams),
});
// 2. 로컬스토리지에 저장되어 있는 북마크 데이터 불러오기
let paredSavedValue: IProductItemWithBookmark[] = [];
if (savedValue) {
paredSavedValue = JSON.parse(savedValue) as IProductItemWithBookmark[];
}
// 3. 북마크 리스트 아이디만 추려서 배열 만들기
let bookmarkIds: Array<number> = [];
if (Array.isArray(bookmarkIds) && Array.isArray(paredSavedValue)) {
bookmarkIds = paredSavedValue.map((v: IProductItemWithBookmark) => v.id);
}
// 4. 상품 아이디가 북마크 아이디 배열에 포함되어 있을 경우 상품 객체에 { isBookmarked: true } 추가
const addBookmarkStatus = response.map((v) => bookmarkIds.includes(v.id) ? { ...v, isBookmarked: true } : v);
return addBookmarkStatus;
},
});
하지만 위에서 문제점!
상품 + 북마크 정보를 화면에 뿌린 후에 북마크 상태를 바꾸려고 하면 저장된 값이 아니기 때문에 상태가 바뀌도록 하는 것이 힘들었다.
selector의 get 함수를 사용하기에 유용한 상황은 다음과 같이 카테고리 별로 데이터를 필터하여 보여주는 경우일 것 같다.
// 상품 타입에 따라 상품 리스트 데이터를 필터하여 화면에 뿌리기 위한 get 함수
export const filterProductListByType = selector({
key: "filterProductListByType",
get: ({ get }) => {
const temp = get(productList);
const type = get(selectedGnbType);
if (type === '') return temp;
return temp.filter(v => v.type === type);
}
})
다시 북마크 상태를 상품 데이터에 추가하는 작업으로 돌아와 selector의 set 함수를 사용해보았다.
위 get 함수를 사용할 때와 다른 점은 데이터를 저장할 atom을 추가하고,
api는 useEffect에서 호출하여 productList에 저장하고
이후 북마크 여부를 더하는 데이터 연산은 set 함수에서 진행한다는 점이다.
// 상품 리스트 api 응답 결과를 저장할 atom
export const productList = atom<IProductItemWithBookmark[]>({
key: "productList",
default: []
});
// 상품 리스트 api 응답 결과에 북마크 여부를 포함하여 productList 상태를 set 할 함수
export const reqGetProductList = selector<IProductItemWithBookmark[]>({
key: "reqGetProductList",
get: ({ get }) => { ... 중략 ... },
set: ({ set }, productListWithoutBookmark) => {
const savedValue = localStorage.getItem("bookmarks");
let paredSavedValue: IProductItemWithBookmark[] = [];
if (savedValue) {
paredSavedValue = JSON.parse(savedValue) as IProductItemWithBookmark[];
}
let bookmarkIds: Array<number> = [];
if (Array.isArray(bookmarkIds) && Array.isArray(paredSavedValue)) {
bookmarkIds = paredSavedValue.map((v: IProductItemWithBookmark) => v.id);
}
let data: IProductItemWithBookmark[] = []
if (Array.isArray(productListWithoutBookmark)) {
data = productListWithoutBookmark;
}
const addBookmarkStatus = data.map((v) => bookmarkIds.includes(v.id) ? { ...v, isBookmarked: true } : v);
set(productList, addBookmarkStatus);
}
});