이전부터 recoil을 언제 사용할지 고민하다가 이번에 사이드 프로젝트를 하면서 사용해 보려고 하던 도중 recoil 공식 문서에 나와있는 개념과 튜토리얼을 정리해 보면 재미있겠다는 생각이 들어서 오늘은 공식문서를 보고 TodoList를 만들어 보면서 recoil 사용법을 정리해 보도록 하겠습니다.
npm install recoil
yarn add recoil
Atom은 Recoil에서 가장 기본적인 상태의 단위입니다. 각 Atom은 애플리케이션 전역에서 사용되는 하나의 상태를 나타냅니다. atom 함수를 사용하여 생성하며, useRecoilState 훅을 통해 해당 상태를 읽고 업데이트할 수 있습니다.
Selector는 Recoil에서 파생된 상태를 계산하는 데 사용됩니다. 다른 Atom이나 Selector의 값을 읽어와서 새로운 값을 계산하고 반환합니다. selector 함수를 사용하여 생성하며, useRecoilValue 훅을 통해 해당 값을 읽을 수 있습니다.
RecoilRoot는 Recoil 상태를 관리하는 컴포넌트 트리의 루트에 위치하는 컴포넌트입니다. Recoil을 전역적으로 사용하기 위해서는 RecoilRoot로 하위 컴포넌트를 감싸야 합니다.
import { RecoilRoot } from 'recoil';
function App() {
return (
<RecoilRoot>
{/* 애플리케이션 컴포넌트들 */}
</RecoilRoot>
);
}
전역으로 사용할 상태(Atom)을 세팅합니다.
const textState = atom({
key: 'textState', // 유니크한 ID (다른 atoms/selectors 과 연관이 있습니다.
default: '', // default 값 (초기화할 값)
});
Atom을 사용할 때는 useRecoilState 훅을 이용하여 useState 훅처럼 사용이 가능합니다.
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>
);
}
만약 우리가 textState 값을 불러와서 text 값에 대한 길이를 반환하는 함수를 만든다면 selector를 이용할 수 있습니다.
const charCountState = selector({
key: 'charCountState',
get: ({get}) => {
const text = get(textState);
return text.length;
},
});
useRecoilValue 훅을 사용해서 charCountState 값을 읽을 수 있습니다. 그러면 text의 길이를 알 수 있습니다.
function CharacterCount() {
const count = useRecoilValue(charCountState);
return <>Character Count: {count}</>;
}
기본적인 사용법을 학습했으니 이번에는 실습을 해보겠습니다. recoil 공식 문서에서 제공하는 투두리스트 만들기를 이해하기 쉽게 풀어 보았습니다.
recoil을 사용하기 전 가장 처음해야하는 설정입니다. 아까 위에서 정리했듯이 루트경로에 부모 컴포넌트로 감싸주면 됩니다.
import { RecoilRoot } from 'recoil';
function App() {
return (
<RecoilRoot>
{/* 애플리케이션 컴포넌트들 */}
</RecoilRoot>
);
}
todo-state.ts 라는 파일을 만들고 우리가 전역적으로 사용할 상태 (atom)을 등록합니다.
import { atom } from "recoil";
interface TodoListInter {
id: string;
text: string;
isComplete: false;
}
type FilterState = string;
export const todoListState = atom<TodoListInter[]>({
key: "todoListState",
default: [],
});
export const todoListFilterState = atom<FilterState>({
key: "todoListFilterState",
default: "Show All",
});
간단한 input 과 button을 만들고 할 일을 추가하는 로직을 만들어 보겠습니다. components 폴더에 todo-create.tsx 파일을 만들고, 아래와 같이 작성해 봅시다.
작성하기 이전에 유니크한 id값을 주기 위해 uuid를 설치하였습니다.
npm i uuid
yarn add uuid
import { useState } from "react";
import { useRecoilValue, useSetRecoilState } from "recoil";
import { v4 as uuidv4 } from "uuid";
import { todoListState } from "../todo-state";
function TodoCreate() {
const todoList = useRecoilValue(todoListState);
const [inputValue, setInputValue] = useState("");
const setTodoList = useSetRecoilState(todoListState);
console.log(todoList);
const addItem = () => {
console.log("Asdf");
setTodoList((oldTodoList) => [
...oldTodoList,
{
id: uuidv4(),
text: inputValue,
isComplete: false,
},
]);
setInputValue("");
};
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};
return (
<>
<input
type="text"
name="addtodo"
id="addtodo"
value={inputValue}
onChange={onChange}
/>
<button onClick={addItem}>add</button>
</>
);
}
export default TodoCreate;
이제 App.tsx 파일에 TodoCreate 컴포넌트를 추가하고 인풋값에 아무거나 텍스트를 입력하고 버튼을 누르면 콘솔에 우리가 작성한 todolist가 잘 추가되는지 확인해 봅시다.
import "./App.css";
import TodoCreate from "./components/todo-create";
function App() {
return (
<div className="App">
<TodoCreate />
</div>
);
}
export default App;
잘 추가되었는지 확인했다면, 이제 components 폴더에 todolist 를 보여줄 컴포넌트인 TodoList 컴포넌트와 하나의 Todo를 나타낼 TodoItem 컴포넌트를 작성하고, 잘 나타나는지 확인해 봅시다.
import { useRecoilValue } from "recoil";
import { todoListState } from "../todo-state";
import "../App.css";
import TodoItem from "./todo-item";
function TodoList() {
const todoList = useRecoilValue(todoListState);
return (
<ul className="todo-list">
{todoList.map((todo) => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
);
}
export default TodoList;
import { TodoListInter } from "../todo-state";
import "../App.css";
interface TodoItemProps {
todo: TodoListInter;
}
function TodoItem({ todo }: TodoItemProps) {
return (
<li className="todo-item">
{todo.text}
<div>
<input
type="checkbox"
name="complete"
id="complete"
checked={todo.isComplete}
onChange={() => {}}
/>
<button>X</button>
</div>
</li>
);
}
export default TodoItem;
위와 같이 입력한 후 잘 추가되었다면 이제 삭제 로직을 작성해 봅시다.
공식 문서에서 사용하는 로직은 간단하게 TodoItem.tsx 에서 todoList 값을 불러오고 그 리스트에서 삭제할 인덱스를 제외한 리스트를 atom 으로 다시 세팅해 주었습니다.
따라서 TodoItem의 코드를 다음과 같이 바꿔주면 됩니다.
import { TodoListInter, todoListState } from "../todo-state";
import "../App.css";
import { useRecoilValue, useSetRecoilState } from "recoil";
interface TodoItemProps {
todo: TodoListInter;
}
function TodoItem({ todo }: TodoItemProps) {
const todoList = useRecoilValue(todoListState);
const setTodoList = useSetRecoilState(todoListState);
// 아이템을 삭제할 인덱스
const index = todoList.findIndex((listItem) => listItem === todo);
// 인덱스를 제외한 값으로 atom을 세팅
const deleteItem = () => {
const newList = removeItemAtIndex(todoList, index);
setTodoList(newList);
};
// 인덱스를 제외한 리스트를 반환
const removeItemAtIndex = (arr: TodoListInter[], index: number) => {
return [...arr.slice(0, index), ...arr.slice(index + 1)];
};
return (
<li className="todo-item">
{todo.text}
<div>
<input
type="checkbox"
name="complete"
id="complete"
checked={todo.isComplete}
onChange={() => {}}
/>
<button onClick={deleteItem}>X</button>
</div>
</li>
);
}
export default TodoItem;
삭제가 되는 것을 잘 확인 했다면 이제 수정을 해 봅시다. 수정도 삭제와 마찬가지로 todoList의 인덱스를 찾고 인덱스 값이 맞는 것을 찾아 새로운 값으로 대체하고 그 리스트를 다시 atom 값으로 세팅해 주면 됩니다.
코드는 아래와 같습니다.
import { TodoListInter, todoListState } from "../todo-state";
import "../App.css";
import { useRecoilValue, useSetRecoilState } from "recoil";
interface TodoItemProps {
todo: TodoListInter;
}
function TodoItem({ todo }: TodoItemProps) {
const todoList = useRecoilValue(todoListState);
const setTodoList = useSetRecoilState(todoListState);
// 아이템을 삭제할 인덱스
const index = todoList.findIndex((listItem) => listItem === todo);
// 인덱스를 제외한 값으로 atom을 세팅
const deleteItem = () => {
const newList = removeItemAtIndex(todoList, index);
setTodoList(newList);
};
// 인덱스를 제외한 리스트를 반환
const removeItemAtIndex = (arr: TodoListInter[], index: number) => {
return [...arr.slice(0, index), ...arr.slice(index + 1)];
};
// 값이 바뀔때마다 atom을 새로운 값으로 세팅
const editItemText = ({ target: { value } }) => {
const newList = replaceItemAtIndex(todoList, index, {
...todo,
text: value,
});
setTodoList(newList);
};
// 리스트의 인덱스 이전인덱스와 이후인덱스 사이에 새로운 값을 넣어서 리턴
const replaceItemAtIndex = (
arr: TodoListInter[],
index: number,
newValue: TodoListInter,
) => {
return [...arr.slice(0, index), newValue, ...arr.slice(index + 1)];
};
return (
<li className="todo-item">
<div>
{/* 바뀐 부분 */}
<input
type="text"
name="id"
id="id"
value={todo.text}
onChange={editItemText}
/>
{/* */}
</div>
<div>
<input
type="checkbox"
name="complete"
id="complete"
checked={todo.isComplete}
onChange={() => {}}
/>
<button onClick={deleteItem}>X</button>
</div>
</li>
);
}
export default TodoItem;
삭제와 수정이 모두 가능해 졌다면 이제 완료 기능을 추가 해 봅시다. 완료 기능도 마찬가지로 인덱스를 찾고 그 인덱스의 완료여부를 체크박스에 따라 false 또는 true로 바꿔주면 됩니다.
따라서 아래의 코드만 TodoItem.tsx에 추가해 주면 됩니다.
// 체크를 할때마다 atom을 새로운 값으로 세팅
const toggleItemCompletion = () => {
console.log("asdf");
const newList = replaceItemAtIndex(todoList, index, {
...todo,
isComplete: !todo.isComplete,
});
setTodoList(newList);
};
삭제,수정, 완료여부 까지 했으니 마지막입니다. 이제 filter를 해서 완료된 것과 완료되지 않은 todo들의 숫자를 화면에 나타내 봅시다.
우선 TodoFilter 라는 새로운 컴포넌트를 생성하고, App.tsx 에 추가 합니다.
import "./App.css";
import TodoCreate from "./components/todo-create";
import TodoFilter from "./components/todo-filter";
import TodoList from "./components/todo-list";
function App() {
return (
<div className="App">
<TodoCreate />
<TodoFilter />
<TodoList />
</div>
);
}
export default App;
그리고 TodoFilter.tsx에는 다음과 같이 작성해 줍니다.
import { useRecoilState } from "recoil";
import { todoListFilterState } from "../todo-state";
function TodoFilter() {
const [filter, setFilter] = useRecoilState(todoListFilterState);
const updateFilter = ({
target: { value },
}: React.ChangeEvent<HTMLSelectElement>) => {
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>
</>
);
}
export default TodoFilter;
이제 완료가 되었다면 selector를 만들어 todolist를 필터링할 때 필요한 함수들을 만들어줄 수 있습니다.
todo-state.ts 파일의 아래에 다음과 같이 입력하면 필요한 selector를 등록할 수 있습니다.
export const filteredTodoListState = selector({
key: "filteredTodoListState",
get: ({ get }) => {
// 투두리스트와 필터상태(atom)들을 가져와서
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;
}
},
});
이렇게 하고나서 반드시 해줘야할 점이 있습니다. useRecoilValue로 값을 불러올 때 키값을 변경해 줘야 합니다. 따라서 TodoList.tsx에서 키값을 변경해 줍니다.
import { useRecoilValue } from "recoil";
import { filteredTodoListState } from "../todo-state";
import "../App.css";
import TodoItem from "./todo-item";
function TodoList() {
// 변경된 부분 키값이 변경됨.
const todoList = useRecoilValue(filteredTodoListState);
return (
<ul className="todo-list">
{todoList.map((todo) => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
);
}
export default TodoList;
아래에서 실습한 결과를 확인할 수 있습니다. css나 파일명은 제가 수정하면서 조금 바뀐 부분이 있을 수 있지만 그 외엔 동일합니다.
recoil 튜토리얼은 공식문서에서 너무 간단하게 나와 있어서 햇갈리는 부분이 있었지만 두번 세번 읽고 나니 너무 편리한 기능이 많이 있는것 같았습니다. 다음 글에는 recoil로 비동기 로직을 구현하는 글을 써 보도록 하겠습니다.
https://app.sideguide.dev/recoil/tutorial
https://recoiljs.org/ko/