본문 바로가기
공부

recoil 소개

by 실패전문개발자 2022. 6. 22.

https://recoiljs.org/ko/docs/introduction/installation

 

설치 | Recoil

NPM

recoiljs.org

 

사용동기

리액트 상태관리의 어려움

  • react context
    • Context가 필요한 최상위 컴포넌트에 Context를 선언하고 사용하면 되지만
    • 한 Context에 하나의 값만 사용가능하기 때문에 다수의 값을 사용하려면 Wrapper Hell에 빠집니다.
    • 예시
  • redux
    • 이해하기 상당히 어렵습니다.
    • 리덕스만의 철학을 따르기 위해 만들어야 하는 부수적인 코드가 증가해 생산성이 좋지 않습니다.
  • recoil
    • 데이터 흐름이 직관적이고 이해하기 쉽습니다.
    • 렌더링이 최적화되어 있습니다. (구독한 atom과 selector가 변경될 시 구독한 컴포넌트만 re-rendering)

 

주요개념

  • recoil을 사용하면 atom(공유 상태)에서 selectors(순수 함수)를 거쳐 react 컴포넌트로 내려가는 data-flow graph를 만들 수 있습니다.

RecoilRoot

  • recoil 상태를 사용하면 컴포넌트는 RecoilRoot가 필요합니다.
  • 루트 컴포넌트가 RecoilRoot를 넣기에 가장 좋은 장소입니다. 
  • import React from 'react';
    import {
      RecoilRoot,
      atom,
      selector,
      useRecoilState,
      useRecoilValue,
    } from 'recoil';
    
    function App() {
      return (
        <RecoilRoot>
          <CharacterCounter />
        </RecoilRoot>
      );
    }
  • 이 코드에서는 App 하위의 컴포넌트에서 모두 recoil 개념을 사용할 수 있습니다.

 

Atom

  • Atom은 상태(state)의 일부를 나타냅니다.
  • 어떤 컴포넌트에서나 읽고 쓸 수 있습니다.
  • atom의 값을 읽는 컴포넌트들은 암묵적으로 atom을 구독합니다.
  • 그래서 atom에 변화가 발생하면 atom을 구독하는 모든 컴포넌트들이 re-rendering 됩니다.
const todoListState = atom({
  key: 'todoListState', // unique ID
  default: [], // default value
});
  • Atom은 위와 같이 atom() 함수를 사용해 생성합니다.
function TodoList() {
  const todoList = useRecoilValue(todoListState);

  return (
    <>
      <TodoListStats />
      <TodoListFilters />
      <TodoItemCreator />

      {todoList.map((todoItem) => (
        <TodoItem key={todoItem.id} item={todoItem} />
      ))}
    </>
  );
}
  • Atom의 값을 위와같이 useRecoilValue(atom)을 통해 읽을 수 있습니다.
function TodoItemCreator() {
  const [inputValue, setInputValue] = useState('');
  const setTodoList = useSetRecoilState(todoListState);

  const addItem = () => {
    setTodoList((oldTodoList) => [
      ...oldTodoList,
      {
        id: getId(),
        text: inputValue,
        isComplete: false,
      },
    ]);
    setInputValue('');
  };

  const onChange = ({target: {value}}) => {
    setInputValue(value);
  };

  return (
    <div>
      <input type="text" value={inputValue} onChange={onChange} />
      <button onClick={addItem}>Add</button>
    </div>
  );
}

// 고유한 Id 생성을 위한 유틸리티
let id = 0;
function getId() {
  return id++;
}
  • Atom의 값을 위와 같이 useSetRecoilState(atom)을 통해 수정할 수 있습니다.
function TodoItem({item}) {
  const [todoList, setTodoList] = useRecoilState(todoListState);
  const index = todoList.findIndex((listItem) => listItem === item);

  const editItemText = ({target: {value}}) => {
    const newList = replaceItemAtIndex(todoList, index, {
      ...item,
      text: value,
    });

    setTodoList(newList);
  };

  const toggleItemCompletion = () => {
    const newList = replaceItemAtIndex(todoList, index, {
      ...item,
      isComplete: !item.isComplete,
    });

    setTodoList(newList);
  };

  const deleteItem = () => {
    const newList = removeItemAtIndex(todoList, index);

    setTodoList(newList);
  };

  return (
    <div>
      <input type="text" value={item.text} onChange={editItemText} />
      <input
        type="checkbox"
        checked={item.isComplete}
        onChange={toggleItemCompletion}
      />
      <button onClick={deleteItem}>X</button>
    </div>
  );
}

function replaceItemAtIndex(arr, index, newValue) {
  return [...arr.slice(0, index), newValue, ...arr.slice(index + 1)];
}

function removeItemAtIndex(arr, index) {
  return [...arr.slice(0, index), ...arr.slice(index + 1)];
}
  • useRecoilState(atom)의 경우 useRecoilValue(atom)과 useSetRecoilState(atom)의 기능을 모두 가진 함수입니다.

 

Selector

  • Selector는 파생된 상태(derived state)의 일부를 나타냅니다.
  • 파생된 상태는 다른 데이터에 의존하는 동적인 데이터를 만들 수 있습니다.
  • 예시) 필터링된 todo list, todo list 통계
const todoListFilterState = atom({
  key: 'todoListFilterState',
  default: 'Show All',
});

const filteredTodoListState = selector({
  key: 'filteredTodoListState',
  get: ({get}) => {
    const filter = get(todoListFilterState);
    const list = get(todoListState);

    switch (filter) {
      case 'Show Completed':
        return list.filter((item) => item.isComplete);
      case 'Show Uncompleted':
        return list.filter((item) => !item.isComplete);
      default:
        return list;
    }
  },
});

const todoListStatsState = selector({
  key: 'todoListStatsState',
  get: ({get}) => {
    const todoList = get(todoListState);
    const totalNum = todoList.length;
    const totalCompletedNum = todoList.filter((item) => item.isComplete).length;
    const totalUncompletedNum = totalNum - totalCompletedNum;
    const percentCompleted = totalNum === 0 ? 0 : totalCompletedNum / totalNum;

    return {
      totalNum,
      totalCompletedNum,
      totalUncompletedNum,
      percentCompleted,
    };
  },
});
  • selector는 위와 같이 atom 혹은 selector의 결과에 따라 상태를 파생시킬 수 있는 순수 함수입니다.
  • 위의 filteredTodoListState selector의 경우 todoListFilterState Atom과 todoListState Atom을 추적합니다.
  • 그래서 Atom 두개 중 하나라도 변하면 이를 구독하고있는 filteredTodoListState 또한 재실행됩니다.
function TodoListFilters() {
  const [filter, setFilter] = useRecoilState(todoListFilterState);

  const updateFilter = ({target: {value}}) => {
    setFilter(value);
  };

  return (
    <>
      Filter:
      <select value={filter} onChange={updateFilter}>
        <option value="Show All">All</option>
        <option value="Show Completed">Completed</option>
        <option value="Show Uncompleted">Uncompleted</option>
      </select>
    </>
  );
}

function TodoListStats() {
  const {
    totalNum,
    totalCompletedNum,
    totalUncompletedNum,
    percentCompleted,
  } = useRecoilValue(todoListStatsState);

  const formattedPercentCompleted = Math.round(percentCompleted * 100);

  return (
    <ul>
      <li>Total items: {totalNum}</li>
      <li>Items completed: {totalCompletedNum}</li>
      <li>Items not completed: {totalUncompletedNum}</li>
      <li>Percent completed: {formattedPercentCompleted}</li>
    </ul>
  );
}
  • 만들고 있던 todo list를 계속 만들어봅니다.

 

비동기 데이터 쿼리

  • 비동기로 데이터를 받아와서 렌더링해야 할 때도 selector를 사용할 수 있습니다.
  • selector에서 get 콜백으로 나온 값 그 자체 대신 Promise를 return하면 인터페이스 그대로 사용할 수 있습니다.
const currentUserIDState = atom({
  key: 'CurrentUserID',
  default: 1,
});

const currentUserNameQuery = selector({
  key: 'CurrentUserName',
  get: async ({get}) => {
    const response = await myDBQuery({
      userID: get(currentUserIDState),
    });
    if (response.error) {
      throw response.error;
    }
    return response.name;
  },
});

function CurrentUserInfo() {
  const userName = useRecoilValue(currentUserNameQuery);
  return <div>{userName}</div>;
}

function MyApp() {
  return (
    <RecoilRoot>
      <ErrorBoundary>
        <React.Suspense fallback={<div>Loading...</div>}>
          <CurrentUserInfo />
        </React.Suspense>
      </ErrorBoundary>
    </RecoilRoot>
  );
}
  • 위 예제에서 currentUserNameQuery는 비동기로 db에서 유저 정보를 받아와 반환해주는 selector 함수입니다.
  • MyApp의 Suspense에서 currentUserNameQuery의 값을 받아올 때 까지 fallback에 있는 컴포넌트를 렌더합니다.
    • recoil은 보류중인 데이터(currentUserName)를 다루기 위해 React Suspense와 동작하게 되어있습니다.
  • currentUserNameQuery에서 error가 발생하면 그 에러가 다른 컴포넌트에 영향이 가지 않게끔 ErrorBoundary로 지정하여 에러를 가둡니다.
const userNameQuery = selectorFamily({
  key: 'UserName',
  get: (userID) => async () => {
    const response = await myDBQuery({userID});
    if (response.error) {
      throw response.error;
    }
    return response.name;
  },
});

function UserInfo({userID}) {
  const userName = useRecoilValue(userNameQuery(userID));
  return <div>{userName}</div>;
}

function MyApp() {
  return (
    <RecoilRoot>
      <ErrorBoundary>
        <React.Suspense fallback={<div>Loading...</div>}>
          <UserInfo userID={1} />
          <UserInfo userID={2} />
          <UserInfo userID={3} />
        </React.Suspense>
      </ErrorBoundary>
    </RecoilRoot>
  );
}
  • 매개변수를 기반으로 상태를 가져오고 싶다고 한다면 selectorFamily를 사용해서 가져올 수 있습니다.
const friendsInfoQuery = selector({
  key: 'FriendsInfoQuery',
  get: ({get}) => {
    const {friendList} = get(currentUserInfoQuery);
    const friends = get(
      waitForAll(friendList.map((friendID) => userInfoQuery(friendID))),
    );
    return friends;
  },
});
  • 여러 데이터를 받아와 한번에 렌더링 해야한다면 waitForAll()을 이용해 데이터를 한번에 렌더링할 수 있습니다.
const friendsInfoQuery = selector({
  key: 'FriendsInfoQuery',
  get: ({get}) => {
    const {friendList} = get(currentUserInfoQuery);
    const friendLoadables = get(
      waitForNone(friendList.map((friendID) => userInfoQuery(friendID))),
    );
    return friendLoadables
      .filter(({state}) => state === 'hasValue')
      .map(({contents}) => contents);
  },
});
  • 여러 데이터를 받아올 때 받아오는 대로 렌더링을 추가하고 싶으면 waitForNone()을 이용해 렌더링할 수 있습니다.
function CurrentUserInfo() {
  const currentUser = useRecoilValue(currentUserInfoQuery);
  const friends = useRecoilValue(friendsInfoQuery);

  const changeUser = useRecoilCallback(({snapshot, set}) => (userID) => {
    snapshot.getLoadable(userInfoQuery(userID)); // pre-fetch user info
    set(currentUserIDState, userID); // change current user to start new render
  });

  return (
    <div>
      <h1>{currentUser.name}</h1>
      <ul>
        {friends.map((friend) => (
          <li key={friend.id} onClick={() => changeUser(friend.id)}>
            {friend.name}
          </li>
        ))}
      </ul>
    </div>
  );
}
  • useRecoilCallback을 사용하면 먼저 callback함수를 실행하고 rendering을 시킬 수 있습니다.
 

'공부' 카테고리의 다른 글

쿠버네티스 학습  (0) 2022.03.24
RabbitMQ vs Kafka vs Redis  (0) 2022.03.11
Log4j 학습  (0) 2022.01.28
Apache vs NGINX, 그리고 NGINX 설정  (0) 2022.01.26