Valtio는 proxy 기반의 라이브러리이다. Proxy에서 상세 라이프사이클을 관리해주기 때문에 상태관리와 로직에만 집중할 수 있다. react에서 제공하는 useSyncExternalStore을 내부적으로 사용하면서 객체값을 직접 변경했을 때 랜더링을 발생시킬 수 있다. Suspense 및 React 18와도 호환된다고 한다.
라이브러리를 학습할 때에는 Todo만한 것이 없는 것 같다. 바로 가보자.
먼저 Todo 상태를 관리할 Class를 생성해준다. 각 상태 값들을 다루는 함수들도 포함해서 만들어준다.
//state/todo/todoState.ts
import { Filter, Todo } from "@/types/todo/todoType";
import { proxy } from "valtio";
import { proxyMap } from "valtio/utils";
export class TodoState {
filter;
todos;
constructor(params: { filter: Filter; todos: Map<string, Todo> }) {
const { filter, todos } = params;
this.filter = filter;
this.todos = todos;
}
add(params: { title: string; completed: boolean }) {
const { title, completed } = params;
if (!title) {
return;
}
const id = crypto.randomUUID();
this.todos.set(id, { id, title, completed });
}
remove(params: { id: string }) {
const { id } = params;
this.todos.delete(id);
}
toggle(params: { id: string }) {
const { id } = params;
const todo = this.todos.get(id);
if (todo) {
todo.completed = !todo.completed;
}
}
setFilter(params: { filter: Filter }) {
const { filter } = params;
this.filter = filter;
}
}
export const globalTodoState = new TodoState({
filter: "all",
todos: proxyMap(),
});
export const globalTodoProxy = proxy(globalTodoState);
proxy 기반이므로 모든 자료형에 대하여 proxy를 제공하지 않는다. Map 같은 형식을 사용하기 위해서는 valtio에서 제공하는 util인 proxyMap을 사용해야한다.
기본적으로 객체를 생성하여 이를 valtio에서 제공하는 proxy 함수에 넘겨주면 기본적으로 this scope가 Proxy 객체가 된다. 우리가 작성한 로직에 추가적으로 리액트 라이프사이클이 동작하게된다. 컴포넌트마다 다른 객체를 생성하여 proxy를 만들 수도 있지만 이 예제에서는 파일에서 객체 및 proxy까지 생성하여 이를 전역으로 다룬다.
// hooks/todo/useTodoFilter.ts
import { useSnapshot } from "valtio";
import { FILTER } from "@/types/todo/todoType";
import { globalTodoProxy } from "@/state/todo/todoState";
export function useFilteredTodos() {
const { todos, filter } = useSnapshot(globalTodoProxy);
const todoArray = [...todos.values()];
if (filter === FILTER.all) {
return todoArray;
}
if (filter === FILTER.completed) {
return todoArray.filter((todo) => todo.completed);
}
return todoArray.filter((todo) => !todo.completed);
}
위의 코드는 완료 여부로 필터링 된 Todo를 가져오기 위한 커스텀 훅이다. 코드에서 useSnapshot이라는 valtio에서 제공하는 훅이 사용되고 있는데 이는 이름 그대로 랜더링 발생 여부를 체크하기 위해 snapshot을 찍는 것과 동일하다. 때문에 값이 변동될 property들은 스냅샷에서 사용하고 우리가 정의했던 비즈니스 로직들은 proxy에서 직접 호출해야한다.
import { globalTodoProxy } from "@/state/todo/todoState";
import { Filter } from "@/types/todo/todoType";
import { FormEvent } from "react";
function TodoFilter() {
const handleChange = (e: FormEvent<HTMLFormElement>) => {
const formData = new FormData(e.currentTarget);
const filter = Object.fromEntries(formData).filter as Filter;
globalTodoProxy.setFilter({ filter });
};
return (
<form onChange={handleChange}>
<label>
<input type="radio" name="filter" value="all" />
전체
</label>
<label>
<input type="radio" name="filter" value="completed" />
완료
</label>
<label>
<input type="radio" name="filter" value="incompleted" />
미완료
</label>
</form>
);
}
export default TodoFilter;
FormData를 활용하여 change 이벤트를 처리하고 proxy에서 setFilter를 호출하여 filter을 변경하고 snapshot과 비교하여 값이 달라졌으면 랜더링 시킨다.
// components/Todo/TodoList.tsx
import { globalTodoProxy } from "@/state/todo/todoState";
import TodoFilter from "./TodoFilter";
import TodoItem from "./TodoItem";
import { useFilteredTodos } from "@/hooks/todo/useTodoFilter";
import { FormEvent } from "react";
function TodoList() {
const filteredTodos = useFilteredTodos();
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const titleInput = e.currentTarget["title-input"];
globalTodoProxy.addTodo({
title: titleInput.value,
completed: false,
});
titleInput.value = "";
};
return (
<div>
<TodoFilter />
<form onSubmit={handleSubmit}>
<input name="title-input" placeholder="제목을 입력해주세요." />
</form>
{filteredTodos.map((todo) => (
<TodoItem key={todo.id} todo={todo} />
))}
</div>
);
}
export default TodoList;
전반적으로 코드에서 setState를 찾을 수 없다. 왜냐하면 proxy에서 setState 동작을 수행해줄 것이고 우리는 좀 더 로직에 집중할 수 있다.
todo를 추가할 때 기존에 클래스 내부에서 정의해둔 addTodo를 proxy에서 호출하면 내부의 값이 변하는 동시에 snapshot과 비교하여 달라졌으면 랜더링이 발생한다.
간단하게 예제를 통해 리액트에서 Valtio를 사용하여 클래스로 상태를 관리하는 방법에 대하여 알아보았다. 꽤 괜찮은 시도인 것 같고 뷰와 로직을 명시적으로 분리할 수 있어서 앞으로 많이 사용할 것 같다.