Please enable JavaScript to view the comments powered by Disqus.

Recoil atomFamily를 사용한 상태 관리

Recoil에 대한 간략한 소개

Recoil은 Facebook에서 만든 React를 위한 상태 관리 라이브러리다. 아직은 버전이 0.3이며(2021년 7월 현재) Redux의 미들웨어처럼 활용할 수 있는 atomEffect의 스펙이 아직 안정화된 상태가 아니긴 하다. 하지만 API 스펙의 안정화, 성능 개선과 함께 Recoil을 기반으로 한 라이브러리가 늘어나면 Redux를 점점 대체해 나갈 것으로 기대된다.

Redux의 상태 관리는 기본적으로 상용구 코드(boilerplate code)가 많이 발생하는데다 combineReducer를 통한 reducer 분할이 가능하다 하더라도 Recoil의 atom과 같은 유연함은 결코 제공할 수 없다. 컴포넌트의 useMemo처럼 파생 데이터를 생성할 수 있는 기능은 자체적으로 가지고 있지 않아서 reselect같은 외부 라이브러리의 힘을 빌려야 한다. 그리고 atomEffect처럼 특정 상태가 업데이트 되었을 때 실행할 사이드 이펙트는 자체적으로 정의할 수 없으며 컴포넌트에서 상태를 구독한 후 useEffect 안에서 구현해야 한다. Redux 미들웨어는 상태 업데이트 후에 실행되는 것이 아니라 dispatch된 모든 액션이 거쳐가는 곳이라 성격이 다르다.

그리고 결정적으로 데이터 구조가 다르다. Redux의 상태 구조는 트리 형태지만 Recoil은 유향 그래프(directed graph) 형태로 구성된다. Recoil에서 데이터의 소스인 atom은 고유한 문자열 키를 가지는데, Recoil은 atom의 키를 구성하는 알파벳을 노드로 하여 그래프를 구성한다(Trie 참조). Recoil에서 atom의 키는 고유한 값을 가져야 하므로 그래프 상의 노드에는 언제나 1개의 atom만 존재하게 된다. 이는 곧 atom이 다른 atom의 영향을 받지 않는 독립적인 상태라는 말이기도 하다. Redux에서 그랬던 것처럼 reducer를 구현할 때 업데이트 대상이 아닌 값(=상태 객체의 다른 필드)들의 불변성을 보장하기 위해 immutable.js, immer 같은 라이브러리의 사용할 고려할 필요가 없다.

이 외에도 Recoil은 React의 동시성 모드(Concurrent Mode) 및 신규 기능과의 호환성이 제공될 수 있으며 atom, selector 앱 개발에 따라 점진적으로 추가할 수 있어서 코드 분할에 용이하다는 점, 비동기 selector를 통한 원격 데이터 구독 등 다양한 특징을 가지고 있다.

atom으로 할일 목록 관리

앱에서는 같은 타입의 상태 객체로 구성된 그룹, 배열이 필요한 경우가 생긴다. 그리고 상태 객체는 동적으로 추가, 수정, 제거될 수 있어야 한다. 예를 들면 할일 목록이 그렇다.

type Todo = {
  id: string;
	title: string;
  isDone: boolean
}

type TodoList = Todo[]

그런데 할일 항목은 동적으로 추가, 삭제되므로 atom으로 하나하나 선언하는 것은 불가능하다. 그러면 TodoList 타입을 가진 atom을 선언해서 상태를 관리하면 되지 않을까?

const todoListState = atom<TodoList>({
  key: 'todoListState',
  default: [],
})

이렇게 가능하다. 하지만 할일 목록의 업데이트를 위해서 atom이 가진 상태는 항상 새로운 배열로 교체되어야 한다.

const App = () => {
  const [todoList, setTodoList] = useRecoilState(todoListState)

  const addTodo = () => {
		setTodoList([...todoList, { id: uuid(), title: '', isDone: false }])
	} 

  const removeTodo = (id) => {
		const target = todoList.findIndex(v => v.id === id)
    const updatedList = [...todoList]
    updatedList.splice(target, 1)
    setTodoList(updatedList)
	} 

  // update todo and so on....
}

Redux로 배열을 제어할 때 많이 보던 방식이다. 그리고 atom 업데이트를 위해 배열을 완전히 교체하는 비용이 발생하므로 불변성을 제공하기 위해 immer 같은 라이브러리를 도입해야 할 것 같다. useState를 쓰는 것과 별다른 점이 없는 것 같은데, 이러면 그냥 Redux를 쓰고 싶어질 것 같다.

하지만 이렇게 atom으로 할일 목록을 관리하는 것이 아니라, 1개의 할일 객체는 1개의 atom에 대응되게 만들 수는 없을까? 그러면 할일을 렌더링하는 컴포넌트에서 1개의 atom만 직접 구독하면 될 것이다. 업데이트할 때도 배열을 교체하는 것이 아니라 구독한 atom만 업데이트해주면 된다.

const TodoComponent = () => {
	 const [todo, setTodo] = useRecoilState(동적으로 만들어진 atom)
}

이런 케이스를 위해 Recoil은 atomFamily API를 제공한다.

atomFamily

atomFamily API는 atom을 리턴하는 팩토리 함수를 만들며, 그 팩토리 함수로 독립적인 atom state를 만들 수있다. 할일 상태를 atomFamily로 작성하면 다음과 같다.

const todoItemState = atomFamily<Todo, string>({
  key: 'todoItemState',
  default: (id) => {
    return {
      id,
      title: '',
      isDone: false,
    };
  },
});

atomFamily의 default 옵션은 다양한 형태로 사용할 수 있는데, 기본적으로 함수 형태로 작성한다. 함수의 파라미터로 팩토리 함수를 호출할 때 사용한 값을 전달할 수 있기 때문이다.

팩토리 함수를 호출할 때는 atom을 구분할 수 있는 고유한 값을 사용해야 상태의 독립을 보장할 수 있다.

const TodoComponent = (id: string) => {	
  const [todo, setTodo] = useRecoilState(todoItemState(id))
	return (
		<div>{todo.title}</div>
	)
}

default 함수에 atom을 구분할 수 있는 고유한 키 값이 전달되므로 그 값을 바탕으로 기본값을 구성할 수 있다. 특히 Recoil의 데이터 그래프는 비동기적으로 동작할 수 있기 때문에 default 함수는 Promise를 리턴하는 것도 가능하다.

default: (Parameter => T | RecoilValue<T> | Promise<T>)
const todoItemState = atomFamily<
  Todo, // 리턴 데이터 타입
  string // atomFamily 팩토리 함수 호출 파라미터
>({
  key: 'todoItemState',
  default: async (id) => {
    const data = await getTodoData(id)
    return {
      id: data.id
      title: data.title,
      isDone: data.isDone,
    };
  },
});

defaultselectorFamily를 사용하면 다른 상태를 참조하는 것이 가능하다. 예를 들어 서버 데이터가 다른 상태에 저장되어 있고, 그 상태를 사용해서 기본값을 구성할 때 유용하게 사용할 수 있다.

export const todoItemState = atomFamily<Todo, String>({
  key: 'todoItemState',
  default: selectorFamily({
    key: 'todoItemState/default',
    get: (id) => ({ get }) => {
      // 아래의 serverDataState는 recoil atom 또는 selector 
      const dataList = get(serverDataState); 
			const target = serverData.find((v) => v.id === id)
	    return  {
	      id: target.id
	      title: target.title,
	      isDone: target.isDone,
	    };
    },
  }),
});

atomFamily로 할일 상태 구현

예제이므로 여기서는 할일을 서버에도, 로컬스토리지에도 저장하지 않는 형태로 구현해보고자 한다.

atomFamily를 사용하기 위해서는 atom을 구분할 수 있는 키가 필요하다는 것을 알았다. 그러면 그 키를 관리할 수 있는 상태가 필요할 것이다.

const todoIdsState = atom<string[]>({
  key: 'todoIdsState',
  default: [],
});

만약 할일이 서버에 저장된다면 todoIdsState 는 문자열의 배열 형태가 아니라 아이디를 포함한 객체의 배열 형태로 사용할 수도 있다. 중요한 것은 고유한 아이디를 가진 데이터의 배열이어야 한다는 점이다. 그래야 그 데이터를 사용해 atomFamily를 사용할 수 있기 때문이다.

할일 상태를 관리할 atomFamily는 앞서 살펴본 것과 같다. 마찬가지로 서버 데이터를 필요하다면 default 함수에 Promise를 리턴하는 비동기 쿼리를 쓰거나 selectorFamily를 사용하면 된다.

const todoItemState = atomFamily<Todo, string>({
  key: 'todoItemState',
  default: (id) => {
    return {
      id,
      title: '',
      isDone: false,
    };
  },
});

상태는 준비가 되었다. 이제 새로운 할일을 추가하기 위해 todoIdsState에 새로운 아이디를 추가하는 컴포넌트가 필요하다.

function NewTodoForm() {
  const addTodo = useRecoilCallback(
    ({ snapshot, set }) =>
      () => {
        const todoIds = snapshot.getLoadable(todoIdsState).getValue();
        set(todoIdsState, [...todoIds, uuid()]);
      },
    [],
  );

  return (
    <button type="button" onClick={addTodo}>
      Add New Item
    </button>
  );
}

export default TodoInputForm;

여기서는 useRecoilCallback을 사용했다. useRecoilState를 사용해도 되지만 저 훅을 사용하면 컴포넌트에서 todoIdsState 상태의 업데이트를 구독하지 않게 된다는 장점이 있다.

이제 할일 아이디 목록을 컴포넌트 목록으로 맵핑해준다.

function TodoList() {
  const todoIdList = useRecoilValue(todoIdsState);

  return (
    <ul>
      {todoIdList.map((id) => {
        return <TodoItem key={id} id={id} />;
      })}
    </ul>
  );
}
export default TodoList;

TodoItem에 atomFamily에 사용할 id를 props로 전달했다. 할일 컴포넌트에서 이렇게 전달된 id로 atomFamily 팩토리 함수를 호출하여 상태를 만든다. 아래의 예제 코드에서는 할일의 제목(title 필드)을 업데이트하는 기능만 작성해 두었다.

interface Props {
  id: string;
}

const TodoItem: React.FC<Props> = ({ id }) => {
  const [todo, setTodo] = useRecoilState(todoItemState(id));

  const handleChangeTodoTitle = useCallback(
    (title: string) => {
      setTodo({
        ...todo,
        title,
      });
    },
    [todo],
  );

  return (
    <li>
      <input
        type="text"
        value={todo.title}
        onChange={(e) => handleChangeTodoTitle(e.target.value)}
        placeholder={'new todo'}
      />
    </li>
  )
};

export default React.memo(TodoItem);

React.memo를 사용하여 TodoItem 을 래핑해두면 새로운 할일이 추가되어도 이미 렌더링된 컴포넌트를 다시 렌더링하지 않는다. 전달하는 props가 id 1개인데다 문자열이기 때문에 다른 할일이 삭제, 추가되어도 memoization이 적용될 것임을 쉽게 추측할 수 있다.

만약 처음 살펴본 것처럼 atomFamily를 사용하지 않고 todoListState 에 할일 객체 배열을 저장하는 방식을 사용한다면 새로운 할일이 추가될 때마다 모든 TodoItem 컴포넌트가 다시 렌더링된다. 아래와 같은 방식으로 업데이트하면 새로운 배열이 생성되며 객체의 레퍼런스도 모두 바뀌기 때문이다.

const addTodo = () => {
	setTodoList([...todoList, { id: uuid(), title: '', isDone: false }])
} 

렌더링 방지를 위해서는 배열에 포함된 객체의 불변성이 보장되도록 해야 한다. 또는 객체의 데이터를 여러 props에 분할해서 제공하거나(레퍼런스 변경이 없는 literal 데이터만 전달되도록) React.memo의 두번째 파라미터에 전달할 비교 함수를 직접 구현해야 할 것이다. 이런 점을 따져보면 atomFamily를 사용한 상태 관리는 다른 atom을 신경쓸 필요가 없는 단순함이 있으며, 최적화에도 도움이 된다는 사실을 확인할 수 있다.

참고자료