오.코.분: pmndrs/jotai/examples todos 폴더
오.코.분 목표: 트렌드를 만들어 내는 pmndrs의 jotai sample을(쉬운것부터) 분석하고 블로그 프로젝트에 jotai 상태관리를 도입해보자.
새로 학습한 내용
- atom 선언시 콜백 함수의 인자로 오는 get 이용 방법
- 배열의 요소로 atom을 직접 생성하여, 외부에서 해당 배열의 요소(atom)를 useAtom으로 선언할 수 있다.
해당 요소는 배열의 요소와 동일한 참조값을 지닌다. - React-Spring 라이브러리를 활용해서 애니메이션을 만들어 봐야겠다.
- 동일한 참조값을 지닌 요소를 remove의 인자로 전달하여 삭제하는 방식을 배웠다. 참조값에 대한 개념이 확장됐다.
오픈 소스 코드 분석 대상
pmndrs/jotai 레포지토리의 examples todoes 폴더
https://github.com/pmndrs/jotai/tree/main/examples/todos
todo 리스트 생성, 삭제, 완료 체크, 필터링 기능을 담고 있는 예제를 볼 수 있다.
App.tsx 파일로 작성되어 있어 타입을 어떻게 작성하는지도 참고할 수 있다.
예제 폴더에선 App.tsx 파일에 컴포넌트를 다 몰아 넣어 놓았기 때문에 여기서는 컴포넌트를 나누어 하나씩 살펴보자
// 컴포넌트 구조
App
│
└── TodoList
│
├── Filter
│
└── Filtered
│
└── TodoItem
App.tsx
type Todo = {
title: string
completed: boolean
}
const filterAtom = atom('all')
const todosAtom = atom<PrimitiveAtom<Todo>[]>([])
const filteredAtom = atom<PrimitiveAtom<Todo>[]>((get) => {
const filter = get(filterAtom)
const todos = get(todosAtom)
if (filter === 'all') return todos
else if (filter === 'completed')
return todos.filter((atom) => get(atom).completed)
else return todos.filter((atom) => !get(atom).completed)
})
export default function App() {
return (
<Provider>
<h1>Jōtai</h1>
<TodoList />
</Provider>
)
}
filterAtom, todosAtom, filteredAtom 세개의 atom을 전역으로 선언한 뒤에
하위 컴포넌트에서 이 아톰들을 이용할 수 있도록 Provider로 감싸주어야 한다.
filteredAtom
const filteredAtom = atom<PrimitiveAtom<Todo>[]>((get) => {
const filter = get(filterAtom)
const todos = get(todosAtom)
if (filter === 'all') return todos
else if (filter === 'completed')
return todos.filter((atom) => get(atom).completed)
else return todos.filter((atom) => !get(atom).completed)
})
filteredAtom의 형태가 다른 atom보다 복잡하니 살펴보자.
첫째,
atom을 선언하면서 get을 콜백함수의 인자로 받고 있다.
get은 다른 atom의 값을 읽어들이는 역할을 한다.
둘째,
filter에 filterAtom의 값을,
todos에 todosAtom의 값을 할당한다.
셋째,
filterAtom의 값에 따라 필터링 된 todos 결과를 반환한다.
read only atom으로 어떠한 값도 변경하지 않고 필터링 된 새로운 배열을 담은 atom을 생성한다.
filterAtom과 todosAtom에 의존하고 있기 때문에 해당 atom들이 변경될 때마다 filteredAtom의 retrun 결과가 달라진다.
TodoList.tsx
const TodoList = () => {
// Use `useSetAtom` to avoid re-render
// const [, setTodos] = useAtom(todosAtom)
const setTodos = useSetAtom(todosAtom)
const remove: RemoveFn = (todo) =>
setTodos((prev) => prev.filter((item) => item !== todo))
const add = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
const title = e.currentTarget.inputTitle.value
e.currentTarget.inputTitle.value = ''
setTodos((prev) => [...prev, atom<Todo>({ title, completed: false })])
}
return (
<form onSubmit={add}>
<Filter />
<input name="inputTitle" placeholder="Type ..." />
<Filtered remove={remove} />
</form>
)
}
atom의 상태를 변경(write)시키기 위해서는
useSetAtom 또는 useAtom 두가지를 이용할 수 있다.
useAtom은 useState처럼 사용하면 되는데,
[ , setTodos] 와 같이 set action 만을 사용할 때는,
useSetAtom으로 코드를 보기 좋게 만들 수 있다는 장점이 있다.
소스 코드를 살펴보면 [useAtomValue, useSetAtom] 을 반환하고 있다.
따라서 useAtom으로 생성한 뒤 setAtom만을 이용하는 것과,
useSetAtom을 사용하는 것과의 작동 방식에 있어 논리적으로 차이는 없다.
export function useAtom<Value, Args extends any[], Result>(
atom: Atom<Value> | WritableAtom<Value, Args, Result>,
options?: Options,
) {
return [
useAtomValue(atom, options),
// We do wrong type assertion here, which results in throwing an error.
useSetAtom(atom as WritableAtom<Value, Args, Result>, options),
]
}
useAtom의 구조를 살펴 보면 useAtomValue와 useSetAtom을 반환하고 있으므로 동일한 함수를 가져다 쓴다는 것을 알 수 있다.
하지만, read only atom과 write only atom을 따로 사용하는 것이 번들링될 코드를 줄일 수 있고 코드를 읽기에도 깔끔하다.
따라서 only atoms를 사용하는 것이 클린 코드적인 관점에서 보았을 때 맞다고는 생각한다.
type Todo = {
title: string
completed: boolean
}
remove 함수는
setTodos와 filter 메서드를 활용하여 todosAtom 배열에서 인자로 들어온 atom 요소를 제거한다. (어떻게 작동하는지 뒤에서 설명)
add 함수는
submit 될 때 실행되며 다음과 같은 작업을 수행한다.
첫째, input의 값을 title 변수에 할당
둘째, input 값 초기화
마지막으로, todosAtom에 atom({ title, completed: fasle }) 요소를 추가한다.
atom을 직접 배열 안에 생성했다. (뒤에서 이 부분 중요함)
Filter.tsx
const Filter = () => {
const [filter, set] = useAtom(filterAtom)
return (
<Radio.Group onChange={(e) => set(e.target.value)} value={filter}>
<Radio value="all">All</Radio>
<Radio value="completed">Completed</Radio>
<Radio value="incompleted">Incompleted</Radio>
</Radio.Group>
)
}
filterAtom<string>의 값을 onChange 함수로 변경한다.
Filtered.tsx
import { a, useTransition } from '@react-spring/web'
const Filtered = (props: FilteredType) => {
const [todos] = useAtom(filteredAtom)
const transitions = useTransition(todos, {
keys: (todo) => todo.toString(),
from: { opacity: 0, height: 0 },
enter: { opacity: 1, height: 40 },
leave: { opacity: 0, height: 0 },
})
return transitions((style, atom) => (
<a.div className="item" style={style}>
<TodoItem atom={atom} {...props} />
</a.div>
))
}
filtered 파일에는 필터링 된 TodoItem 목록을 나열하여 렌더링하는 역할을 한다.
여기서 a와 useTransition이 나타나는데 이게 뭔지 잠깐 확인하고 넘어가자.
여기의 useTransition은 react 18에서 제공하는 작업 우선순위 지정 실행 기능과는 관련이 없다. (해당 작업에 우선순위를 부여하여 렌더링을 일시 중단하고 작업을 바로 처리할 수 있도록 함)
React-Spring 라이브러리는
리액트에서 스프링 원리를 기반으로 애니메이션을 구현하기 쉽도록 만든 라이브러리다.(라는데 궁금하면 직접 찾기)
대략 살펴보자면 a 태그는 애니메이션을 포함할 태그,
useTransition은 array 데이터를 받아 각각의 요소들에 대해 애니메이션을 지정할 수 있다.
그리고 animation이 적용된 요소를 렌더링한다.
한마디로, 배열의 모든 요소들에 같은 옷을 입히는 작업이다.
로직을 보면 대략 다음과 같이 동작한다는 것을 예상할 수 있다.
return {todos.map((todo) => <AnimatedDiv style={yourStyle}>{todo}</AnimatedDiv>}
todos 배열에 map으로 렌더링을 하기 전 콜백 함수로 애니메이션이 적용된 박스로 감싸고,
style 데이터는 AnimatedDiv로, 그 자식에는 todo 데이터를 전달한다.
TodoItem.tsx
const TodoItem = ({ atom, remove }: TodoItemProps) => {
const [item, setItem] = useAtom(atom)
const toggleCompleted = () =>
setItem((props) => ({ ...props, completed: !props.completed }))
return (
<>
<input
type="checkbox"
checked={item.completed}
onChange={toggleCompleted}
/>
<span style={{ textDecoration: item.completed ? 'line-through' : '' }}>
{item.title}
</span>
<CloseOutlined onClick={() => remove(atom)} />
</>
)
}
따라서, TodoItem은 하나의 todo(atom) 정보를 props로 받는 컴포넌트다.
toggleCompleted 함수는
props로 전달 받은 todo의 completed 상태를 업데이트 한다.
<CloseOutlined .../> 태그는
그냥 아이콘이다.
remove 함수를 실행하여 전달 받은 atom을 todos에서 제거한다.
TodoItem에서 깨달은 중요한 점은 (앞에서 중요하다고 한 부분)
하나의 todo를 props로 전달받았는데, 이 todo는 todos에 포함된 요소를 참조하고 있는 객체란 것이다.
remove에 전달하는 것은 동일한 데이터를 가진 객체일 뿐만 아니라 todos 배열의 요소와 동일한 참조값을 가지고 있다.
const [item, setItem] = useAtom(atom)
useAtom을 사용할 수 있었던 것은 todos의 요소 하나하나는 모두 atom으로 생성된 객체이기 때문이다.
const add = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
const title = e.currentTarget.inputTitle.value
e.currentTarget.inputTitle.value = ''
setTodos((prev) => [...prev, atom<Todo>({ title, completed: false })])
}
그래서 remove 함수에 해당 todo를 전달하는 행위는 단순 동일한 데이터의 객체를 전달하는 것이 아니라,
해당 atom의 참조값을 전달하는 것으로써 해당 요소를 삭제할 수 있었던 것이다.
(이런 방식으로 이용할 수 있다는 것을 처음 알았다. 천재다.
지금까지는 배열의 요소를 props로 전달하고, 해당 요소가 지닌 속성 중 하나를 이용하여 삭제해왔다.
신선한 충격을 받았다. 분명 동일한 참조값이거늘,,, 어떻게 이런 생각을 하지?
참조값에 대한 개념이 확장 된 기분이다.)