0w0

(TS) 이제와서 묻기 애매한 recoil

머릿말

이번에는 Recoil에 대해 소개(+ 자신의 학습 메모)하려합니다.

RecoilFacebook이 개발한 React를 대상으로하는 상태관리 전반을 다루는 라이브러리입니다.

이글에서도 다뤄고있지만 Zenn의 상태관리에도 사용하고 있는데다가, 지금 점유도 확대하고 있는 라이브러리입니다.

JavaScript 구현 예시는 종종 보이므로 이번에는 TypeScript 사용예시를 소개하겠습니다.

애초에 Recoil이 뭔죠?

구현하기 전에 먼저 Recoil에 대해 말해야겠죠.

RecoilFacebook이 2020년 5월에 발표한 상태관리 라이브러리입니다. 공식

비슷하게 상태관리하는 Redux가 나사하나 빠진 인상인데, 실제로 사용하면 꽤 별납니다. 지나칠 정도로 데이터 스토어를 집약할 것인가 아닌가 개념이 일반적 개념과 크게 다른 것이 시스템에 멋대로 반영된 이미지입니다.

React Hook와도 잘 어우러지면서 useState 같은 것을 사용하면서 Functional Component + Hooks를 바탕으로 개발했다면 금방 이해될 것이라 생각합니다.

주의할 점은 현재는 아직 Experimental 레포지토리이므로 앞으로의 지원같은걸 고려해서 제품에 욱여넣는 건 아직 이르다 생각합니다.

구현 샘플

구현한 앱의 샘플은 이러합니다.

sample app

흔히 보는 TODO 앱으로 작업 추가, 검색할 수 있습니다.

버전 정보

구현

다음과 같은 순서로 구현을 했습니다.

프로젝트 만들기

npx create-react-app recoil-ts-sample --template typescript
cd recoil-ts-sample

Recoil 설치

yarn add recoil

types 작성

이 앱을 다룰 "작업" type를 만듭시다. 위치는 src/types/Todo.ts 간단하게 title만 갖는 Todo타입를 정의했습니다.

type Todo = {
  title: string;
};
export default Todo;

Atom 작성

이어서 Atom 작성 AtomRecoil에서 데이터저장소를 말하며 atom()이라 선언합니다. atom()에는 AtomOptions 타입 오브젝트를 전달합니다. AtomOptions는 아래와 같습니다.

// atom.d.ts
export interface AtomOptions<T> {
  key: NodeKey;
  default: RecoilValue<T> | Promise<T> | T;
  effects_UNSTABLE?: ReadonlyArray<AtomEffect<T>>;
  dangerouslyAllowMutability?: boolean;
}

key, default는 필수입니다. key는 앱 전체에 영향을 끼치는 문자열, defaultAtom 역할로 상태관리 행하고 싶은 값 초기값입니다.

예를 들어 앱에서 "작업 목록"를 Atom 선언하면 다음과 같습니다.

// src/atoms/TodoListAtom.ts
import { atom } from 'recoil';
import Todo from '../types/Todo';

export const todoListState = atom<Todo[]>({
  // key는 "todoList"
  key: 'todoList',

  // 초기값으로 3건의 작업을 갖는 배열 선언
  default: [{ title: 'one' }, { title: 'two' }, { title: 'three' }],
});

상단에 "추가하고 싶은 작업명 필드", "검색문자열 필드" 상태인 Atom를 선언합시다.

// src/atome/TodoTitleFormAtom.ts
import { atom } from 'recoil';

export const todoTitleFormState = atom<string>({
  key: 'todoTitleForm',
  default: '',
});
// src/atoms/SearchTextFromAtom.ts
import { atom } from 'recoil';

export const searchTextFormState = atom<string>({
  key: 'searchTextForm',
  default: '',
});

Atom 선언은 Redux에서 Store라 생각하면 이해하기 쉬울지도 모르겠습니다.

Selector 작성

Selctor도 똑같이 작성합니다. SelctorAtom 값을 사용해 어떤 계산이나 가공, 부작용을 처리해 결과를 반환하는 것입니다.

이번 앱에서는 "화면에 게시되는 작업목록"이 Selector를 사용합니다. 이는 화면에 게시되는 작업은 [작업 전체]에서 [검색 필드에 입력된 문자]에 해당하는 문자를 포함하는 것을 보이기 위함입니다.

Selector 선언은 selector()로 행합니다. 인수로 ReadOnlySelectorOptions 타입 객체를 전달해야합니다. ReadOnlySelectorOptions 타입은 이렇습니다.

export interface ReadOnlySelectorOptions<T> {
  key: string;
  get: (opts: { get: GetRecoilValue }) => Promise<T> | RecoilValue<T> | T;
  dangerouslyAllowMutability?: boolean;
}

Atom처럼 key를 가지고 있습니다. 여기에 [어떻게 값을 얻을까] 정의하는 get도 있습니다. get인수로 GetRecoilValue 타입의 get(프로퍼티 명이랑 같은게 좀 괴상하지만...)를 프로퍼티에 갖는 객체를 받는 함수입니다.

Selector를 사용하면 이렇게 됩니다.

// src/selectors/SearchedTodoListSelctor.ts
import { selector } from 'recoil';
import { todoListState } from '../atoms/TodoListAtom';
import { searchTextFormState } from '../atoms/SearchTextFormAtom';
import Todo from '../types/Todo';

export const searchedTodoListSelector = selector<Todo[]>({
  key: 'searchedTodoListSelector',
  // get은 { get }를 인수로 받는 함수
  get: ({ get }) => {
    // 인수 get를 사용해 Atom에서 최신 값의 받는다(작업 목록)
    const todoList: Todo[] = get(todoListState);
    // 똑같이 검색 필드 문자열 취득
    const searchText: string = get(searchTextFormState);
    // 검색 필드에 입력하는 경우 그 조건에 맞는 작업만 반환
    return searchText
      ? todoList.filter((t) => t.title.includes(searchText))
      : todoList;
  },
});

Root 선언

이것으로 Recoil 상태관리를 행하는 준비를 마쳤습니다. Recoil로 상태관리 하기 위해서는 상태관리를 하고 싶은 각 장소를 <RecoilRoot>로 감싸야 합니다. Redux에서 <Provider>로 감싸는 것과 같은 느낌입니다.

index.tsx안에 RecoilRoot를 사용해 App를 감쌉시다.

// index.tsx

import React from 'react';
import ReactDOM from 'react-dom';
import { RecoilRoot } from 'recoil';
import App from './App';

ReactDOM.render(
  <React.StrictMode>
    <RecoilRoot>
      <App />
    </RecoilRoot>
  </React.StrictMode>,
  document.getElementById('root')
);

/// ...후략

Atom, Selector에서 값을 얻기

그럼 컴포넌트 안에 Atom, Selector에게 값을 얻어봅시다. 값을 얻기 위해서는 useRecoilValue()를 사용합니다. useRecoilValue()에는 Atom, Selector가 인수로 전달됩니다. 그 반환 값은 Atom, Selector가 최신 값으로 반환해주죠.

[작업 목록]을 게시하는 TodoList.tsx는 이렇게 됩니다.

// src/component/TodoList.tsx

import { useRecoilValue } from 'recoil';
import { searchedTodoListSelector } from '../selectors/SearchedTodoListSelector';
import Todo from '../types/Todo';

const TodoList: React.FC = () => {
  // useRecoilValue에 searchedTodoListSelctor를 전달
  // 반환 값은 searchedTodoListSelector의 get()으로 정의한 Todo[]
  const list: Todo[] = useRecoilValue(searchedTodoListSelector);
  return (
    <div>
      <p>작업 목록</p>
      <ul>
        {list.map((todo: Todo, i: number) => {
          return <li key={`${todo.title}_${i}`}>{todo.title}</li>;
        })}
      </ul>
    </div>
  );
};

export default TodoList;

Atom 값 변경

Redux에서 Store 값은 Action, Dispatch로 변경하면 참조하고 있는 컴포넌트에서 리렌더링합니다. Recoil에서도 Atom 값을 변경하면 참조하고 있는 Selector나 컴포넌트에 자동반영됩니다.

Atom 값을 변경하기 위해서는 useSetRecoilState()Atom 값을 인수로 주면 됩니다. 반환 값으로 SetterOrUpdater<T> 타입 함수를 반환합니다. 이는 Atom 값에 대한 Setter 함수 같은 것으로 이 함수를 경유해 Atom를 갱신하면 앞에 재계산처리를 합니다.

예를 들어 [추가하는 작업명을 입력하는 필드] 컴포넌트 TitleForm.tsx는 이렇게 됩니다.

// src/component/TitleForm.tsx

import { useCallback } from 'react';
import { useRecoilValue, useSetRecoilState, SetterOrUpdater } from 'recoil';
import { todoTitleFormState } from '../atoms/TodoTitleFormAtom';

const TitleForm: React.FC = () => {
  // useRecoilValue로 todoTitleFormState 얻기

  const todoTitleFormValue: string = useRecoilValue(todoTitleFormState);
  // useSetRecoilState로 todoTitleFormState의 값을 갱신하는 Setter 함수 얻기

  const setTodoTitleFormValue: SetterOrUpdater<string> =
    useSetRecoilState(todoTitleFormState);
  // 텍스트 필드 onChange 처리
  const onChange = useCallback(
    (event: React.ChangeEvent<HTMLInputElement>) => {
      // 앞에 얻은 setTodoTitleFormValue에 갱신하고 싶은 새 값 전달 실행
      setTodoTitleFormValue(event.target.value);
    },
    [setTodoTitleFormValue]
  );

  return (
    <label>
      작업명:
      <input
        type="text"
        value={todoTitleFormValue}
        onChange={onChange}
        name="title"
        style={{ margin: 12 }}
      />
    </label>
  );
};

export default TitleForm;

똑같이 [문자열 입력하는 필드] 컴포넌트도 만들어 둡시다.

// src/component/SearchForm.ts

import { useCallback } from 'react';
import { useRecoilValue, useSetRecoilState, SetterOrUpdater } from 'recoil';
import { searchTextFormState } from '../atoms/SearchTextFormAtom';

const SearchForm: React.FC = () => {
  const searchTextFormValue: string = useRecoilValue(searchTextFormState);
  const setSearchTextFormValue: SetterOrUpdater<string> =
    useSetRecoilState(searchTextFormState);
  const onChange = useCallback(
    (event: React.ChangeEvent<HTMLInputElement>) => {
      setSearchTextFormValue(event.target.value);
    },
    [setSearchTextFormValue]
  );

  return (
    <label>
      검색:
      <input
        type="text"
        value={searchTextFormValue}
        onChange={onChange}
        name="title"
        style={{ margin: 12 }}
      />
    </label>
  );
};

export default SearchForm;

[작업 추가] 버튼 컴포넌트도요

// src/component/AddButton.tsx

import { useCallback } from 'react';
import { useRecoilValue, useSetRecoilState, SetterOrUpdater } from 'recoil';
import { todoTitleFormState } from '../atoms/TodoTitleFormAtom';
import { todoListState } from '../atoms/TodoListAtom';
import Todo from '../types/Todo';

const AddButton: React.FC = () => {
  const todoList: Todo[] = useRecoilValue(todoListState);
  const todoTitleFormValue: string = useRecoilValue(todoTitleFormState);
  const setTodoList: SetterOrUpdater<Todo[]> = useSetRecoilState(todoListState);
  const setTitleFormValue: SetterOrUpdater<string> =
    useSetRecoilState(todoTitleFormState);

  const onClick = useCallback(() => {
    setTodoList([...todoList, { title: todoTitleFormValue }]);
    // 작업을 추가 후 입력란 비우기
    setTitleFormValue('');
  }, [todoList, todoTitleFormValue, setTodoList, setTitleFormValue]);

  return <button onClick={onClick}>추가</button>;
};

export default AddButton;

App 수정

필요한 컴포넌트을 완성했습니다. App.tsx를 수정해서 Recoil를 사용하는 컴포넌트를 사용합시다.

// App.tsx
import './App.css';
import TitleForm from './component/TitleForm';
import AddButton from './component/AddButton';
import TodoList from './component/TodoList';
import SearchForm from './component/SearchForm';

const App: React.FC = () => {
  return (
    <div style={{ margin: 12 }}>
      <div>
        <TitleForm />
        <AddButton />
      </div>
      <SearchForm />
      <TodoList />
    </div>
  );
};

export default App;
yarn start

이번에 만든 것

위의 소스는 GitHub공개되어있습니다. 잘 동작하지 않는다면 참조해주시길 바랍니다.

맺음말

Recoil에 대해 TypeScript를 사용한 간단한 ToDo 앱을 소개했습니다. 보통은 Redux를 사용해 개발하지만 Recoil이 더 간결하면서 직관적이므로 이해가 더 좋습니다.

Hooks 비슷한 느낌이므로 이것이 강점이 될 것 같습니다.

아직 Experimental이므로 앞으로 사용하는 감각이나 지원이 크게 변할 가능성도 있지만 개인적으로는 더 점유가 많아졌으면 하면 유익한 라이브러리입니다.