Recoil

Kay·2023년 7월 12일
0

"recoil": "^0.7.7"

Redux에 이어 Recoil 을 공부하고 프로젝트에 적용해 보고자 한다.

Recoil 이란?

Redux를 공부할 때 간단히 작성해보았던 상태관리 라이브러리의 발전 과정이다.

  1. 직접 state 관리하기
  • 작은 프로젝트에서는 편하게 관리할 수 있지만 규모가 커질 경우 props drilling 등의 문제가 발생할 수 있다.
  1. Flux 패턴 - 대표적인 redux
    아키텍쳐에 대해 자세히 설명해주신 분이 계셔서 이 글 참고하시면 도움이 될 것 같습니다!
    -> 프론트엔드에서 MV* 아키텍쳐란 무엇인가요?
  • 많은 개발팀에서 사용하지만 학습곡선과 설정에 대한 사전지식이 필요하다. reduxjs/toolkit의 등장으로 방대했던 보일러플레이트가 줄었고, 비통기 통신에 필요한 함수를 제공하여 사용이 용이해졌다.
  1. Flux 패턴 + 구독
  • Mobx는 class 문법과 어노테이션으로 객체지향에 익숙한 개발자들의 사랑을 받았으며, 문법 또한 초기 redux에 비해 비교적 쉬운 편이라 많은 회사에서 도입했었다.
  • 하지만, 리액트가 class 컴포넌트가 아닌 함수형 컴포넌트를 권장하며 자연스레 다른 상태 관리 라이브러리로 넘어가는 것으로 보인다.
  1. atomic 패턴

Recoil 시작하기

useState에 익숙한 분들이라면 Recoil 시작하기 만 보고 금방 사용할 수 있을 것 이다.

1. react 앱 만들기

recoil은 리액트에서 사용하기 위한 상태관리 라이브러리이므로 우선 리액트 앱을 만들어준다.

create-react-app으로 타입스크립트 리액트 앱 만드는 커맨드

npx create-react-app recoil-example-app --template typescript

또는

vite 로 타입스트립트 리액트 앱 만드는 커맨드

npm create vite@lastes -- --template react-ts

2. recoil 라이브러리 설치하기

npm install recoil

3. RecoilRoot 추가하기

src 폴더 안 index.tsx 또는 App.tsx에 추가하면 되는데,
개인적으로 App.tsx에 프로젝트 세팅 관련된 설정을 모두 추가하는 것을 선호하는 편이다.

import { RecoilRoot } from 'recoil';

function App() {
  return (
    <RecoilRoot>
      <Container />
    </RecoilRoot>
  );
}

return App;

4. Atom 사용하기

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;

5. selector 사용하기

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

쉽게 이해되던 atom 사용법과 다르게 select는 개념과 사용법을 이해하는데 헤맸고, 그러던 중 recoil의 atom과 select를 비교한 좋은 글을 발견했다.

Recoil, 리액트의 상태관리 라이브러리

위 글을 요약하자면 다음과 같다.

즉 “ atom 을 원하는 대로 변형해 값을 리턴받는다. ” 라고 생각할 수 있겠습니다. 이 과정을 마치 데이터베이스에서 저장된 데이터를 Select 을 통해 원하는 결과를 뽑아오는 과정으로도 유추 해볼 수 있겠다 라는 생각을 하였고, 이를 Select 과 연결하여 추상화 하니 이해하기 편했습니다.
또한 selector 은 readonly 한 값 만을 반환합니다. 따라서 Recoil 을 활용할 때 수정 가능한 값을 반환 받고자 한다면 반드시 atom 을 활용해야 합니다.

다음 내용은 recoil을 적용한 프로젝트에 대해 코드리뷰를 받으며, atom과 select에 대해 여쭤봤을 때 받은 답변이다.

공식 문서 상에는 기본적인 상태는 atom으로 관리하고, 이를 기반으로 계산된 결과는 selector를 통해 관리하면 좋다고 나와 있네요.예를 들어 페이지 혹은 컴포넌트가 구독하는 상태 값이 리스트고 리스트 자체를 구독하면 atom을 만일 필더링 된 리스트 상태라면 selector를 사용하면 될 것 같습니다.

어렴풋이 이해를 하고 하나씩 적용해갔다.

상태를 스토리지에 저장하기 위한 방법 - atom effect

다음 글은 atom effect를 적용할 때 참고했던 글이며, 로컬스토리지 외에도 서버데이터, 쿠키 등의 값으로 초기화 하는 방법에 대한 설명이 담겨있다.

Recoil with Storage (feat. effects)

atom effect with localstorage

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

selector에서 set 함수를 사용하면 atom의 상태를 바꾸게 할 수 있다.

만약 atom에 넣을 데이터를 만드는 연산이 복잡할 경우 다음과 같은 단점이 있을 수 있다.

  • atom 값에 대한 연산을 컴포넌트 내에서 할 경우 코드가 복잡해진다.
  • 컴포넌트 내에서 연산을 하며, 불필요한 렌더링을 발생시킬 수 있다.

이럴 때 selector의 get 함수 또는 set 함수를 쓰면 좋다.

selector의 활용

selector의 Get

비동기 데이터 쿼리 - 데이터를 저장하지 않고 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 함수

다시 북마크 상태를 상품 데이터에 추가하는 작업으로 돌아와 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);
    }
});

여기서 주의할 점은 selector의 set 함수에 받는 파라미터는 반드시 저장할 데이터와 타입이 같아야한다는 것이다.

그 외

  1. atom과 selector를 사용할 때 파라미터를 받고 싶다면, atomFamily, selectorFamily를 사용하면 된다.
  2. 비동기 처리가 가능하다.
  3. 로딩 처리하기 위한 React.Suspense, useRecoilValueLoadable, useRecoilStateLoadable

0개의 댓글