ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Recoil 도입기
    Frameworks, Platforms and Libraries/React 2024. 5. 19. 15:27

    기존 레거시 프로젝트를 리팩토링하면서  Recoil을 도입하게 되었습니다. 이전에 React 프로젝트에서는 Redux을 사용했기 때문에 팀 내부에도 기술전파가 필요하여 내용을 정리하게 되었습니다.

     

    Recoil?

    Recoil은 Facebook 팀에서 만든 상태 관리 라이브러리로 공식 페이지에서도 확인할 수 있듯이 Recoil을 React를 위한 상태 관리 라이브러리로 표명하고 있습니다. Recoil에서는 atom이라는 상태 단위와 selector라는 파생된 상태를 나타내는 순수 함수를 통해 상태를 나타냅니다. 자세한 내용은 사용 방법에서 알아보겠습니다.

     

    Recoil vs Redux

    출처: https://haruair.github.io/flux/docs/overview.html

    Redux는 Flux 아키텍처를 베이스로 만들어진 상태 관리 라이브러리로 트리 형태의 store 구조를 가집니다. 비동기 처리를 위해 redux-thunk, redux-saga를 사용해야 했고 RTK(redux-toolkit)가 등장하여 상대적으로 간결해졌지만 실제로 사용 시 많은 보일러 플레이트 생산과 높은 러닝 커브의 단점을 가지고 있었습니다.

     

     

    출처: https://www.youtube.com/watch?v=_ISAA_Jt9kI, https://techblog.yogiyo.co.kr/recoil%EC%9D%84-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%86%90%EC%89%AC%EC%9A%B4-%EC%83%81%ED%83%9C%EA%B4%80%EB%A6%AC-b70b32650582

     

    recoil의 경우 atoms와 atoms 및 selectors를 참조하는 파생 상태인 selectors를 컴포넌트에서 구독하고 atoms에 직접 접근해 상태를 업데이트하는 directive 한 구조를 가지고 있습니다. redux와 비교하여 심플한 구조를 가지며 실제로 사용 시 간단한 전역 설정과 react hooks와 같은 문법을 사용하여 react 개발자에겐 상대적으로 접근이 쉽습니다.

     

    Recoil 사용 방법

    기본 설정

    recoil을 패키지에 설치후 main에서 RecoilRoot를 통해 recoil provider를 설정합니다.

    ...
    import { RecoilRoot } from 'recoil';
    ...
    
    const app = createRoot(document.getElementById('root')!);
    app.render(
      <RecoilRoot>
        <Suspense fallback={<div>loading</div>}>
          <RouterProvider router={router} />
        </Suspense>
      </RecoilRoot>,
    );

     

    Atoms

    Atoms는 recoil의 기본 전역 상태 단위입니다. atom 선언 시 해당 atom의 고유 key 값과 default 값을 기본적으로 세팅해 줍니다. 컴포넌트에서 atom을 구독 시 해당 key 값을 이용하여 구독합니다. 그리고 atom이 업데이트가 되면 atom을 구독하고 있는 모든 컴포넌트에서는 리렌더링이 발생합니다. 기본 사용 방법을 예시를 통해 알아보겠습니다.

    // atom/index.ts
    import { atom } from 'recoil';
    
    interface TodoItem {
      id: string;
      title: string;
      desc: string;
    }
    
    // 원시형 atom
    export const countState = atom<number>({
      key: 'countState',
      default: 0,
    });
    
    // 참조형 atom
    export const todoListState = atom<TodoItem[]>({
      key: 'todoListState',
      default: [],
    });

     

    atom을 구독하여 사용하는 방식을 알아보겠습니다.

    // CountExample.tsx
    import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
    import { countState } from './atom';
    
    function CountExample() {
      const [count, setCount] = useRecoilState(countState);
      const countValue = useRecoilValue(countState);
      const setCountValue = useSetRecoilState(countState);
    
      return (
        <div>
          Count useRecoilState
          <button onClick={() => setCount((count) => count + 1)}>
            count is {count}
          </button>
    
          Count useRecoilValue, 
          <button onClick={() => setCountValue((count) => count + 1)}>
            count is {countValue}
          </button>
        </div>
      );
    }
    
    export default CountExample;

     

    실제 개발 시 useRecoilState, useRecoilValue, useSetRecoilState 3가지 hooks는 atoms을 get, set 할 수 있는 hooks로 동시에 쓸 일은 없지만 예시를 위해 사용했습니다. useRecoilState는 useState와 비슷한 형식을 가지며 atom으로 선언된 상태값과 setter 함수를 동시에 가져올 수 있습니다. useRecoilValue과 useSetRecoilState은 각각 상태 값과 setter 함수를 가져오는 hooks입니다. 해당 hooks를 통해 뒤에서 설명할 selector도 구독하여 사용할 수 있습니다.

     

    Selectors

    Selectors는 상태를 다른 상태로 나타내는 순수함수이며 이를 통해 파생된 상태를 나타낼수 있습니다. 또한 비동기 처리시에 사용되는데 이 부분은 selectorFamily와 같이 알아보도록 하겠습니다.

     

    Selectors의 예시입니다. selector 선언 시 기본적으로 key와 get을 입력하게 하고 옵셔널 하게 set을 지정할 수 있습니다. set을 지정하면 양방향성 데이터 흐름을 가지게 되는데 파생된 상태 값을 가지는 selector에서 양방향성이라는 키워드가 낯설지만 해당 부분도 예시를 통해 알아보겠습니다.

    // atom/index.ts
    import { selector } from 'recoil';
    
    export const countState = atom<number>({
      key: 'countState',
      default: 0,
    });
    
    export const countDoubleSelector = selector<number>({
      key: 'countDoubleSelector',
      get: ({ get }) => get(countState) * 2,
      set: ({ set }, newValue) => set(countState, newValue as number / 2),
    });
    // CountExample.tsx
    import { useRecoilState } from 'recoil';
    import { countSelector, countState } from './atom';
    
    function CountExample() {
      const [count, setCount] = useRecoilState(countState);
      const [doubleCount, setDoubleCount] = useRecoilState(countSelector);
    
      return (
        <div>
          <button onClick={() => setCount((count) => count + 1)}>
            count is {count}
          </button>
    
          <button onClick={() => setDoubleCount(doubleCount + 2)}>
            double count is {doubleCount}
          </button>
        </div>
      );
    }
    
    export default CountExample;

     

    첫 번째 버튼을 눌러 count 상태 값이 1이 늘어나면 count 상태 값을 참조하는 selector인 doubleCount는 count의 2배로 늘어난 값을 가지게 됩니다. 그리고 doubleCount는 setter 함수를 지정했기 때문에 setter 함수를 통해 selector의 값을 업데이트할 수 있고 이에 따라 참조하는 atom 상태 값도 업데이트됩니다. setter 함수를 지정하지 않을 경우 단방향성으로 selector 파생된 상태 값만 가져올 수 있습니다.

     

    atomFamily와 selectorFamily with async

    atomFamily와 selectorFamily는  atom, selector 팩토리 함수로 생각하면 되며 넘기는 파라미터를 토대로 독립적인 atom과 selector가 만들어집니다. selectorFamily 예제와 함께 selectors을 이용한 비동기 처리도 같이 알아보겠습니다.

     

    import { atom, atomFamily } from 'recoil';
    
    interface TodoItem {
      id: string;
      title: string;
      desc: string;
    }
    
    export const todoListState = atom<TodoItem[]>({
      key: 'todoListState',
      default: [],
    });
    
    export const todoItemFamily = atomFamily<TodoItem, string>({
      key: 'todoItemFamily',
      default: (id) => ({
        id,
        title: 'title',
        desc: 'desc',
      })
    });
    import { useRecoilValue } from 'recoil';
    
    import { todoItemFamily } from './atom';
    
    function TodoItem({ id }: { id: string }) {
      const todo = useRecoilValue(todoItemFamily(id));
    
      return (
        <div>
          id: {todo.id}
          title: {todo.title}
          desc: {todo.desc}
        </div>
      );
    }
    
    export default TodoItem;

     

    atomFamily는 atom 팩토리 함수로 독립적인 atom을 생성합니다.

     

    selectorFamily의 설명은 selectors을 이용한 비동기 처리 예제와 함께 살펴보겠습니다.

    // atom/star.ts
    import { selector, selectorFamily } from 'recoil';
    
    export const recoilStarSelector = selector({
      key: 'recoil/star',
      get: async () => {
        const response = await fetch(
          'https://api.github.com/repos/facebookexperimental/Recoil'
        );
        const result = await response.json();
        return result.stargazers_count;
      }
    });
    
    export const projectStarSelector = selectorFamily({
      key: 'project/star',
      get: (path: string) => async () => {
        if (!path) return '...';
        const response = await fetch(
          `https://api.github.com/repos/${path}`
        );
        const projectInfo = await response.json();
        return projectInfo.stargazers_count;
      }
    });

     

    selector 선언 시 get에 비동기 처리 로직의 결과를 반환하도록 작성합니다. selectorFamily의 경우 파라미터로 전달받는 값을 api 주소로 활용하여 작성합니다. 해당 selector와 selectorFamily를 사용하는 예제도 같이 보겠습니다.

     

    // AsyncComponent.tsx
    import { useRecoilValue } from 'recoil';
    
    import { recoilStarSelector } from './atom';
    
    function AsyncComponent() {
      const starCount = useRecoilValue(recoilStarSelector);
      
      return (
        <div>
          AsyncComponent
          {starCount}
        </div>
      );
    }
    
    export default AsyncComponent;
    // MultiAsyncComponent.tsx
    import { useState } from 'react';
    
    import { useRecoilValue } from 'recoil';
    
    import { projectStarSelector } from './atom';
    
    function MultiAsyncComponent() {
      const [path, setPath] = useState<string>('');
      
      const starCount = useRecoilValue(projectStarSelector(path));
    
      return (
        <div>
          MultiAsyncComponent
          <select onChange={(e) => setPath(e.target.value)}>
            <option value="">Select Project</option>
            <option value="facebook/react">React</option>
            <option value="facebookexperimental/Recoil">Recoil</option>
          </select>
          {starCount}
        </div>
      );
    }
    
    export default MultiAsyncComponent;

     

    selector와 selectorFamily를 통해 비동기 처리에 대한 결과 값을 확인할 수 있습니다. selectorFamily의 경우 파라미터로 전달하는 값에 따라 다른 값을 받아오는 것을 확인할 수 있습니다.

     

    recoil에서 비동기 처리 시 따로 server status loading 처리를 하지 않았을 경우 React Suspense에 설정된 loader가 동작하며 따로 server status에 따른 설정이 필요할 경우 recoil의 loadable을 이용하면 됩니다.

    // loadable example
    import { useState } from 'react';
    
    import { useRecoilValueLoadable } from 'recoil';
    
    import { projectStar } from './atom';
    
    function MultiAsyncComponent() {
      const [path, setPath] = useState("");
      
      const starLoadable = useRecoilValueLoadable(projectStar(path));
    
      return (
        <div>
          MultiAsyncComponent
          <select onChange={(e) => setPath(e.target.value)}>
            <option value="">Select Project</option>
            <option value="facebook/react">React</option>
            <option value="facebookexperimental/Recoil">Recoil</option>
          </select>
          <br />
          {starLoadable.state === 'hasValue' && <>Stars: {starLoadable.contents}</>}
          {starLoadable.state === 'loading' && <div>loading!!!</div>}
        </div>
      );
    }
    
    export default MultiAsyncComponent;

     

    recoil 다운 상태 업데이트 useRecoilCallback

    아래의 예시 코드를 살펴보겠습니다.

    // atom/index.ts
    import { atom } from 'recoil';
    
    export interface TodoItem {
      id: string;
      title: string;
      desc: string;
    }
    
    export const todoListState = atom<TodoItem[]>({
      key: 'todoListState',
      default: [],
    });
    // AddTodoList.tsx
    import { useRecoilState } from 'recoil';
    import { TodoItem, todoListState } from './atom';
    
    function AddTodoList() {
      const [todoItems, setTodoItems] = useRecoilState(todoListState);
    
      const addTodo = (item: TodoItem) => {
        setTodoItems([...todoItems, item]);
      };
    
      return (
        <div>
          AddTodoList
          <button
            onClick={() =>
              addTodo({
                id: 'temp',
                title: 'temp',
                desc: 'desc',
              })
            }
          >
            add
          </button>
        </div>
      );
    }
    
    export default AddTodoList;

     

    해당 컴포넌트에서 todoListState의 상태를 변경하는 로직을 위해 todoListState를 구독하고 있습니다. todoListState의 상태가 변경될 경우 해당 컴포넌트에서는 리렌더링이 발생하게 됩니다. 해당 코드를 useRecoilCallback로 리팩토링 해보겠습니다.

    // AddTodoList.tsx
    import { useRecoilCallback } from 'recoil';
    
    import { todoListState } from './atom';
    
    function AddTodoList() {
      const addTodo = useRecoilCallback(
        ({ snapshot, set }) =>
          () => {
            const todoItems = snapshot.getLoadable(todoListState).getValue();
            set(todoListState, [
              ...todoItems,
              {
                id: 'temp',
                title: 'temp',
                desc: 'desc',
              },
            ]);
          },
        [],
      );
    
      return (
        <div>
          AddTodoList
          <button onClick={() => addTodo()}>add</button>
        </div>
      );
    }
    
    export default AddTodoList;

     

    useRecoilCallback에서는 snapshot을 이용하여 필요한 상태를 구독하지 않고 접근할 수 있으며 비동기 처리에서도 최신의 상태를 가져올 수 있습니다. 또한 snapshot의 사용으로 상태를 직접 구독하지 않기 때문에 해당 상태가 다른 컴포넌트에서 변경되더라도 리렌더링이 발생하지 않습니다. 예제에선 하나의 상태만을 가져왔지만 여러 상태를 구독하는 경우 useRecoilCallback을 통한 성능 개선이 더욱 돋보일 수 있습니다.

     

    마무리

    recoil의 여러 기능을 예시 코드와 함께 알아보았습니다. 실제로 프로젝트에 도입 시 작업환경에 맞게 사용해야 되며 소개 드린 기능 포함 recoil의 다른 기능들도 좀 더 디벨롭 해야 되겠습니다. 많은 라이브러리들이 개발자들을 더 편하게 발전하는 것처럼 상태 관리 라이브러리도 점점 발전하고 있습니다. 프로젝트 환경에 맞게 잘 도입하여 사용하면 좋겠습니다.


    참고 자료

    https://recoiljs.org/ko/docs/introduction/core-concepts

    https://techblog.yogiyo.co.kr/recoil%EC%9D%84-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%86%90%EC%89%AC%EC%9A%B4-%EC%83%81%ED%83%9C%EA%B4%80%EB%A6%AC-b70b32650582

    https://blog.rhostem.com/posts/2021-11-24-recoil-writable-selector

    https://leirbag.tistory.com/148

     

     

Designed by Tistory.