티스토리 뷰

Redux와 MobX보다 러닝커브가 적은 Recoil을 프로젝트에서 주로 사용을 했지만 뭔가 제대로 썼다는 느낌을 못 받아서 앞으로 남은 데브코스 최종 플젝때는 제대로 써보는걸 목표로 잡고 블로그에 공부내용을 정리하려고 한다.
(+FE스터디 발표)

 

Recoil은 Redux와 MobX와 다르게 React를 위해 출시된 Facebook에서 개발한 전역상태관리 라이브러리이다.

React의 hooks와 어울리면서 React스럽게 제작했다고 한다.

 

Installation

npm install recoil

 

 

RecoilRoot

모든 곳에서 recoil을 사용하기 때문에 루트 컴포넌트가 RecoilRoot를 넣기에 가장 적합하다.

import React from 'react';
import {
  RecoilRoot,
  atom,
  selector,
  useRecoilState,
  useRecoilValue,
} from 'recoil';

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

 

 

Atom

const textState = atom({
  key: 'textState', // unique ID (다른 atom/selectos와 구별하기 위함)
  default: '', // default value (initial value)
});

Atom은 상태(state)의 일부를 나타내고, 어떤 컴포넌트에서나 읽고 쓸 수 있다.

atom의 값을 읽는 컴포넌트들은 암묵적으로 atom을 구독하고 있고 atom에 변화가 있으면 구독하고 있는 모든 컴포넌트들이 리렌더링된다.

 

useRecoilState

컴포넌트가 atom을 읽고 쓰게 하기 위해서 다음과 같이 사용한다.

function CharacterCounter() {
  return (
    <div>
      <TextInput />
      <CharacterCount />
    </div>
  );
}

function TextInput() {
  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>
  );
}

useRecoilState hook은 atom의 상태를 get, set할 수 있다.

const [text, setText] = useRecoilState(textState);

 

text는 textState의 value를 갖게되고, setText는 text의 값을 변경할 수 있다.

리액트 hook의 useState와 매우 비슷함

 

그리고 get과 set을 분리하여 독립적으로 사용할 수 있다.

 

useRecoilValue와 useSetRecoilValue

function TextInput() {
	// const [text,setText] = useRecoilState(textState);
	const text = useRecoilValue(textState);
	const setText = useSetRecoilValue(textState);

	const onChange = (event) => {
		setText(event.target.value);
	};
	...
}

 

 

Selector

(사실 이 글을 쓰는 이유가 Selector를 제대로 알고 싶어서다.)

 

공식문서에 나온 Selector는 파생된 상태(derived state)의 일부를 나타낸다. 여기서 파생된 상태란 상태의 변화를 말한다.

즉, 원래의 state를 그냥 가져오는 것이 아닌, get 프로퍼티를 통해 state를 가공하여 반환할 수 있다.

 

atom의 textState를 가공하여 length 받아오기

const charCountState = selector({
  key: 'charCountState', // unique ID (with respect to other atoms/selectors)
  get: ({get}) => {
    const text = get(textState);

    return text.length;
  },
});

 

useRecoilValue을 사용해서 charCountState값을 읽을 수 있다. 실행결과

function CharacterCount() {
  const count = useRecoilValue(charCountState);

  return <>Character Count: {count}</>;
}

 

우리는 이런 Selector의 특성을 이용하여 필터링 기능도 만들 수 있다.

 

필터링 된 TodoList 예제를 통해 Selector를 좀 더 활용해보자.

사용하게 될 필터 옵션은 "Show All", "Show Completed" 와 "Show Uncompleted"가 있다. 기본값은 "Show All"이다.

const todoListFilterState = atom({
  key: 'todoListFilterState',
  default: 'Show All',
});

todoListFilterStatetodoListState를 사용해서 필터링 된 리스트를 파생하는 filteredTodoListState Selector를 구성할 수 있다.

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;
    }
  },
});

필터링 된 todo list를 표시하는건 단순히 get만 해오면 된다.

function TodoList() {
  // changed from todoListState to filteredTodoListState
  const todoList = useRecoilValue(filteredTodoListState);

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

      {todoList.map((todoItem) => (
        <TodoItem item={todoItem} key={todoItem.id} />
      ))}
    </>
  );
}

이제 필터를 해주는 TodoListFilters 컴포넌트를 구현해보자.

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>
    </>
  );
}

 

이렇게 Selector를 이용해서 TodoList의 필터 기능을 넣을 수 있다.

위 코드는 공식문서에 나온 Selector 코드 예제인데 Selector를 이용해서 api통신과 같은 과정을 한번에 처리해 줄 수 있지 않을까? 생각이 들었다.

 

Selector를 사용하지 않고 atom만으로 api통신을 한다면 다음과 같은 코드일 것이다. (앞으로 나올 코드 출처)

import React, { useState, useEffect } from 'react'  
import { useRecoilState } from 'recoil';
import { cookieState } from '../../recoil';
import {Loading, Card} from '../../components';

const Cookies = () =>{
  const [cookie, setCookie] = useRecoilState(cookieState);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    (async () => {
      const { data } = await getApi.getCookies();
      setCookie(data);
      // 다음과 같이 컴포넌트에서 직접 비동기 처리를 해주고, api 통신 결과로 받아온 data를 직접 atom에 set 해줍니다.
    })();
    setLoading(false);
  }, [setCookie]);

return (
  <>
  {Loading ? 
   <Loading /> // 로딩 중 일때 보여줄 view
   :
   (<div>
        {cookie.map(cookie => (
          <Card
            cookies={cookie}
            key={cookie.id}
            idx={cookie.id}
           />
       ))}
      </div>)
  </>
}

export default Cookies;

 

물론 atom의 get, set만을 사용해도 무리가 없지만 Selector에는 캐싱 기능이 있어서, 성능적으로 유리하다고 한다.

 

Selector와 비동기처리

Selector는 Recoil에서 파생된 상태를 나타낸다. 주어진 종속성 값 집합에 대해 항상 동일한 값을 반환하는 부작용이 없는 순수함수라고 생각하면 된다. (순수함수란?)

 

get함수만 제공되면 Selector는 읽기만 가능한 RecoilValueReadOnly객체를 반환하고,

set함수가 같이 제공되면 Selector는 쓰기 가능한 RecoilState객체를 반환한다.

export const cookieState = atom({
  key: 'cookieState',
  default: []
});

export const getCookieSelector = selector({
  key: "cookie/get",
  get: async ({ get }) => {
    try{
      const { data } = await client.get('/cookies');    
      return data.data;
    } catch (err) {
    	throw err;
    }
  },
  set: ({set}, newValue)=> {
    set(cookieState, newValue)
  }
});

 

set함수에서 주의할 점은 자기 자신의 selector를 set하려고 하면 스스로를 해당 set function에서 set하는 것이기 때문에 무한루프가 돌게 된다. 그래서 반드시 다른 selector와 atom을 set하는 로직을 구성해야한다.
set: ({set}, newValue) =>{ set(getCookieSelector, newValue) } // incorrect : cannot allign itself
set: ({set}, newValue) =>{ set(cookieState, newValue) } // correct : can allign another upstream atom that is writeable RecoilState​

 

Selector의 타입

function selector<T>({
  key: string,

  get: ({
    get: GetRecoilValue
  }) => T | Promise<T> | RecoilValue<T>,

  set?: (
    {
      get: GetRecoilValue,
      set: SetRecoilState,
      reset: ResetRecoilState,
    },
    newValue: T | DefaultValue,
  ) => void,

  dangerouslyAllowMutability?: boolean,
})
key: selector를 구분할 수 있는 유일한 id
get: 파생된 상태를 return하는 함수. 예시코드에서는 api call을 통해 받아온 data를 return하게 된다.
set: 쓰기가능한 state값을 변경할 수 있는 함수를 return하는 곳. 즉 set으로는 atom의 RecoilState만 설정할 수 있다.

 

Selector로 코드개선

import { getCookieSeletor } from '../../reocil';

const Cookies = () =>{
  const [cookie, setCookie] = useRecoilState(getCookieSelector);
}

return (
  <>
   (<div>
        {cookie.map(cookie => (
          <Card
            cookies={cookie}
            key={cookie.id}
            idx={cookie.id}
           />
       ))}
      </div>)
  </>
});

export default Cookies;

 

selector는 결국 다른 selector나 atom의 값을 get해올 수 있고, get해온 값을 바탕으로 다른 atom의 state를 설정할 수 있는 state를 수정할 수 있는 역할을 한다.

 

 

하지만 순조롭게 될 줄 알았던 코드에서 다음과 같은 에러가 뜬다고 한다.

https://velog.io/@juno7803/Recoil-Recoil-200-%ED%99%9C%EC%9A%A9%ED%95%98%EA%B8%B0#4%EF%B8%8F%E2%83%A3---selector-%EC%99%80-%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%B2%98%EB%A6%ACsuspense-loadable

 

에러가 뜨는 이유는 비동기 처리를 하고 있을때 (렌더링 할 데이터가 아직 도착하기 전) 보여줄 fallback UI가 없어서 생기는 에러이다.

 

두가지 방법으로 해결이 가능하다.

  1. React.Suspense로 비동기 상태 처리
  2. Recoil의 Loadable

Recoil의 Loadable만 알아보자.

 

위의 Selector로 코드개선한 코드에서 useRecoilState를 useRecoilValueLoadable로 바꿔주면 된다.

import { getCookieSeletor } from '../../reocil';
import { useRecoilState, useRecoilValueLoadable } from 'recoil';

const Cookies = () => {
  const cookieLoadable = useRecoilValueLoadable(getCookieSelector);

  switch(cookieLoadable.state){
    case 'hasValue':
      return (
        <>
          (<div>
    	    {cookieLoadable.contents.map(cookie =>(
              <Card
                cookies={cookie}
                key={cookie.id}
                idx={cookie.id}
               />
            ))}
	     </div>)
	  </>
	});
     case 'loading':
  	return <Loading />;
     case 'hasError':
     	throw cookieLoadable.contents;
}


export default Cookies;

Loadable은 atom이나 selector의 현재 상태를 나타내는 객체이고 statecontents라는 프로퍼티를 갖고 있다.

state: atom이나 selector의 상태를 말하며, hasValue, hasError, loading 이 세가지의 상태를 가질 수 있다.
contents: atom이나 contents의 값을 나타내며, 상태에 따라 다른 값을 가지고 있다.
 - hasValue : value
 - hasError : Error 객체
 - loading : Promise

 

selectorFamily - 동적인 url

api통신을 할때 외부에서 파라미터로 값을 받아와야하는 경우가 많다. (ex. 페이지네이션)

Selector에서 적용해야할 경우에 selectorFamily를 사용하면 된다.

 

const myDataQuery = selectorFamily({
  key: 'MyDataQuery',
  get: (queryParameters) => async ({get}) => {
    const response = await asyncDataRequest(queryParameters);
    if (response.error) {
      throw response.error;
    }
    return response.data;
  },
});

function MyComponent() {
  const data = useRecoilValue(myDataQuery({userID: 132}));
  return <div>...</div>;
}

 

 

 

Recoil에선 Redux-toolkit 같은 dev-tools가 없어서 그 부분이 가장 치명적인 단점이라고 한다.

 

근데 찾아보니 RecoilizeRecoil Dev Tools 라는 dev-tools 크롬 확장 프로그램이 개발되고 있는 것 같다.

특히 Recoilize는 꽤 괜찮아보이는데.. dev-tools가 없는게 익숙해서인지 잘 모르겠다.

 

 

출처


 

 

Recoil

A state management library for React.

recoiljs.org

 

 

[Recoil] Recoil 200% 활용하기

아무리 구글링해도 Recoil 기본 예제밖에 나오지 않아 직접 작성한 Recoil 200% 활용법 🙃

velog.io