빠르고 쉬운 Recoil.js 상태 관리

그간 recoil.js를 사용하면서 느낀점과 경험을 공유합니다.

2021-02-22

상태 관리 회고

여러 단계의 parent/child 컴포넌트로 이루어진 애플리케이션을 만들다보면 상태 관리와 전달을 고민하게 됩니다.

애플리케이션 전체에서 공통으로 참조하고 변경하는 상태를 흔히 application level state 라고 합니다.

Redux가 없었을 때에는 event emitter 등으로 application level state를 컴포넌트간에 전달하고 forceUpdate 같은 것을 호출 했을 겁니다.

Redux는 상태 관리를 action, reducer, connect 등으로 패턴화 하였습니다.

네 Redux는 일종의 코드를 이렇게 작성하자는 약속과 패턴의 영역이라고 생각합니다.

그러다 보니, redux 방식은 생각보다 많은 코드를 작성해야 합니다.

또한 reducer라는 것은 컴포넌트 단에 드러나지 않아 코드를 이해하고 결과를 예측하기 위해 여러 파일을 추적해야 합니다.

그래서 저는 reducer에서 별도의 변환 로직을 넣지 않고 오로지 새로운 객체에 값을 복사하는 방식으로만 사용하는 편 입니다.

솔직히 딱히 다른 대안이 없기에 redux 패턴을 사용했습니다.

하지만, 하나의 상태를 추가히기 위해 action을 정의하고 connect하는 과정은 지루한 작업입니다.

막연하게 무언가 부족하다고 느껴질 때 즈음 recoil.js를 만나게 됩니다.

action 대신 atom을 사용하고, reducer 대신 selector를 사용하여 reactive한 프로그래밍을 할 수 있습니다.

atom - 컴포넌트간 상태 공유

컴포넌트사이의 상태 공유는 간단히 atom을 정의하면 됩니다.

값만을 사용할 때에는 useRecoilValue를 변경까지 할 경우에는 useRecoilState를 사용합니다.

너무 간단해서 더 설명할 것이 없습니다.

참조: Atoms | Recoil

selector - reactive programminig

흔히들 엑셀의 동작과 비교하는 reactive programming 기법이 있습니다.

엑셀의 SUM 함수를 사용하면 여러 셀의 값을 더하여 그 결과를 표시합니다.

이후 SUM 함수로 지정한 셀 중의 하나를 수정하면 바로 합산 결과를 보여줍니다.

애플리케이션 사용자는 이러한 방식의 동작을 기대합니다.

selector는 recoil state를 get이라는 함수로 참조하는 것으로 observe할 수 있습니다.

참조: Selectors | Recoil

Event emitter나 observer 같은 방식보다 직관적인 코드로 derives한 값을 정의할 수 있습니다.

async API 호출

로그인 사용자 정보는 다양한 컴포넌트에서 필요합니다.

여러 곳에서 사용하더라도 API 호출을 한번만 하고 싶습니다.

먼저 selector로 promise를 반환하도록 아래와 같이 작성합니다.

const userState = selector({
  key: 'userState',
  get: async () => axios.get<UserInfo>('/login-user-info').then((r) => r.data),
});

Header에서도 사용하고

function Header() {
  const userInfo = useRecoilValue(userState)

  return (
    ...
  )
}

UserInfoPage 컴포넌트에서도 사용합니다.

function UserInfoPage() {
  const userInfo = useRecoilValue(userState)

  return (
    ...
  )
}

한 화면에 Header, UserInfoPage 컴포넌트를 동시에 mount해도 한번의 API만 호출합니다.

Reactive programming

Reactive programming은 어떻게 구현할 수 있는지 보겠습니다.

// 현재 화면에 표시할 post의 id
const currentPostIdState = atom({
  key: 'currentPostIdState',
  default: 0,
});

// post 정보
const currentPostState = selector({
  key: 'currentPostState',
  get: async ({ get }) => {
    const currentPostId = get(currentPostIdState);
    return axios.get<Post>(`/posts/${currentPostId}`).then((r) => r.data);
  },
});

// post에 달린 comment 목록
const currenPostCommentsState = selector({
  key: 'currenPostCommentsState',
  get: async ({ get }) => {
    const currentPostId = get(currentPostIdState);
    return axios
      .get<Comment[]>(`/posts/${currentPostId}/comments`)
      .then((r) => r.data);
  },
});

위의 코드는 currentPostIdState를 변경할 경우 currentPostState, currenPostCommentsState를 새로운 API 호출과 함께 갱신합니다.

on 함수를 사용한 callback 처리나 observe를 사용한 chaining function 없이도 매우 직관적이고 간결한 코드로 reactive programming을 할 수 있습니다.

조금 복잡하지만 매우 유용한 예제를 살펴 보겠습니다.

Comment가 10개 일지라도 작성자는 2명일 수 있습니다.

그리고 여러분은 작성자 정보를 가져오는 API 호출 횟수를 줄이고 싶을 겁니다.

먼저 CommentuserId에 따른 UserInfo값을 가지는 ResolvedComment를 다음과 같이 정의합니다.

type Comment = {
  userId: string; // 커멘트 작성자
  comment: string; // 커멘트 내용
};

type ResolvedComment = Comment & {
  user: UserInfo; // `userId`로 부터 가져온 사용자 정보
};

selectorFamily를 사용하면 파라미터를 받을 수 있는 state를 정의할 수 있습니다.

파라미터는 serializable 즉 JSON.stringify 가능해야 합니다.

아마도 recoil.js 내부에서 캐쉬 key로 사용할 것 같습니다.

waitForAll과 함께 사용하면 마법과 같은 코딩을 할 수 있습니다.

// userId 값으로 사용자 정보를 가져옵니다.
const userFamilyState = selectorFamily({
  key: 'userFamilyState',
  get: (userId: string) => async () => {
    return axios.get<UserInfo>(`/user/${userId}`);
  },
});

// 커멘트 목록 정보를 업데이트하면 커멘트 작성자 정보와 함께 업데이트 합니다.
const currentResolvedCommentsState = selector({
  key: 'currentResolvedCommentsState',
  get: ({ get }) => {
    const comments = get(currenPostCommentsState);

    // 각 comment의 작성자 정보를 매번 가져오는 것 같지만, selectorFamily로 인해 중복 호출을 하지 않습니다.
    const users = get(
      waitForAll(comments.map(({ userId }) => userFamilyState(userId)))
    );
    return comments.map((comment) => ({
      ...comment,
      user: findItem(users, 'id', comment.useId),
    }));
  },
});

이제 currentPostIdState 만 변경하면 중복 호출 없이 각 커멘트의 작성자 정보까지 업데이트하는 간결하고 직관적인 라이브러리를 확보하였습니다.

원하는 recoil state를 원하는 컴포넌트에 useRecoilValue로 hook하면 끝 입니다.

마치며

무언가를 완벽히 알고 사용하기 보다는, 필요한 것을 찾아 사용하는 편 입니다.

그래서 아직 recoil.js의 기능을 모두 아는 것은 아닙니다.

하지만, 다양한 요구사항에 대한 해결 방법을 고민했을 때, atomselector라는 이 간단한 개념들이 얼마만큼 확장가능한지 매번 놀라고 있습니다.

이런 새로운 개념으로 인해 금방이라도 legacy가 되어 버릴 redux나 mobx 기반의 코드 작성을 잠시 멈추시고, recoil.js를 사용해 보시는 것 어떨까요?

추천 드립니다.

Loading script...