단순한 컴포넌트 내부 상태는 useState로 충분하지만, 전역적으로 공유해야 하는 데이터가 많아질수록 관리가 복잡해집니다.
2020년 페이스북(메타)이 공개한 상태관리 라이브러리입니다.
초반에는 React 생태계에서 Redux 대안으로 큰 관심을 받았지만, 지금 기준(2025년)으로는 예전 기술로 보는 게 맞습니다.
Recoil의 현재 상황
- 2020~2021년
"React 전용 전역 상태 관리"로 주목받음. atom/selector 개념 덕분에 러닝커브가 Redux보다 쉽다는 평가. - 2022~2023년
React 18(Concurrent Mode)과 잘 맞긴 했지만, React Query, Zustand, Jotai 같은 가볍고 단순한 라이브러리가 많이 나오면서 점점 밀리기 시작. - 2024년 말
메타가 사실상 개발을 중단했고, 2025년 1월 1일부로 공식 GitHub 저장소가 아카이브 처리됨 → 신규 기능/버그 수정 없음. - React 19 (Next.js 15 기본)과 호환성이 깨짐 → ReactCurrentDispatcher 오류 다수 발생.
Recoil
Recoil은 Facebook이 만든 React 전용 전역 상태 관리 라이브러리입니다.
- Hooks 기반 API: useRecoilState, useRecoilValue, useSetRecoilState 등 React 훅과 유사한 사용성
- Atom + Selector: 최소 상태 단위와 파생 상태의 명확한 분리
- Concurrent Features 친화적: Suspense/동시성 기능과 자연스럽게 연동
- 비동기 흐름 내장: Selector로 동기/비동기 파생 상태 모두 간단히 표현
핵심 개념: Atom & Selector
Atom
- 전역 상태의 최소 단위입니다.
- 여러 컴포넌트가 동시에 구독할 수 있고, 값이 바뀌면 해당 값을 구독 중인 컴포넌트만 리렌더링됩니다.
// src/state/todo.ts
import { atom } from 'recoil';
export const todoListState = atom<string[]>({
key: 'todoListState', // 전역적으로 고유해야 함
default: [],
});
Selector
- 파생 상태(Derived state)를 정의합니다.
- 하나 이상의 Atom/Selector를 입력으로 받아 계산된 값을 반환합니다.
- 동기/비동기 모두 가능하며, 캐싱/메모이제이션이 기본 제공됩니다.
// src/state/todo.ts
import { selector } from 'recoil';
import { todoListState } from './todo';
export const todoStatsState = selector<{ total: number; completed: number }>({
key: 'todoStatsState',
get: ({ get }) => {
const todos = get(todoListState);
const completed = todos.filter((t) => t.startsWith('[완료]')).length;
return { total: todos.length, completed };
},
});
셋업
npm install recoil
# 또는
pnpm add recoil
- Recoil을 사용하려면 최상위에 RecoilRoot를 감쌉니다.
// src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { RecoilRoot } from 'recoil';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<RecoilRoot>
<App />
</RecoilRoot>
</React.StrictMode>
);
예제: Todo List
Atom 정의
// src/state/todo.ts
import { atom } from 'recoil';
export const todoListState = atom<string[]>({
key: 'todoListState',
default: [],
});
// 완료/전체 개수 파생 상태
export const todoStatsState = selector<{ total: number; completed: number }>({
key: "todoStatsState",
get: ({ get }) => {
const todos = get(todoListState);
const completed = todos.filter((t) => t.startsWith("[완료]")).length;
return { total: todos.length, completed };
},
});
쓰기/읽기: useRecoilState
// src/components/TodoInput.tsx
import { useRecoilState } from 'recoil';
import { todoListState } from '../state/todo';
import { useState } from 'react';
export default function TodoInput() {
const [todos, setTodos] = useRecoilState(todoListState);
const [text, setText] = useState('');
const add = () => {
if (!text.trim()) return;
setTodos((prev) => [...prev, text.trim()]);
setText('');
};
return (
<div>
<input value={text} onChange={(e) => setText(e.target.value)} placeholder="할 일을 입력" />
<button onClick={add}>추가</button>
</div>
);
}
읽기 전용: useRecoilValue
// src/components/TodoList.tsx
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { todoListState } from '../state/todo';
export default function TodoList() {
const todos = useRecoilValue(todoListState); // 읽기 전용
const setTodos = useSetRecoilState(todoListState); // setter만
const toggleComplete = (idx: number) => {
setTodos((prev) =>
prev.map((t, i) => (i === idx ? (t.startsWith('[완료]') ? t.replace('[완료] ', '') : `[완료] ${t}`) : t))
);
};
return (
<ul>
{todos.map((t, i) => (
<li key={i}>
<button onClick={() => toggleComplete(i)}>완료토글</button> {t}
</li>
))}
</ul>
);
}
파생 상태: Selector
// src/components/TodoStats.tsx
import { useRecoilValue } from 'recoil';
import { todoStatsState } from '../state/todo';
export default function TodoStats() {
const { total, completed } = useRecoilValue(todoStatsState);
return <p>총 {total}개 / 완료 {completed}개</p>;
}
조립
// src/App.tsx
import TodoInput from './components/TodoInput';
import TodoList from './components/TodoList';
import TodoStats from './components/TodoStats';
export default function App() {
return (
<main>
<h1>Recoil Todo</h1>
<TodoInput />
<TodoStats />
<TodoList />
</main>
);
}
비동기 : Selector로 API 호출하기
Recoil은 별도 미들웨어 없이 Selector만으로도 비동기 로직을 표현할 수 있습니다.
// src/state/user.ts
import { selector } from 'recoil';
export type User = { id: number; name: string };
export const currentUserQuery = selector<User>({
key: 'currentUserQuery',
get: async () => {
const res = await fetch('/api/user');
if (!res.ok) throw new Error('Failed to fetch user');
return (await res.json()) as User;
},
});
// src/components/UserProfile.tsx
import { useRecoilValue } from 'recoil';
import { currentUserQuery } from '../state/user';
export default function UserProfile() {
const user = useRecoilValue(currentUserQuery);
return <div>{user.name}님 환영합니다!</div>;
}
Recoil 체크리스트
key는 전역 고유 문자열
- Atom/Selector의 key는 앱 전체에서 유일해야 합니다.
- 동적 생성 시 충돌 방지에 주의하세요. (2편에서 atomFamily/selectorFamily 소개)
상태는 쪼개서 설계
- 큰 객체 하나로 모든 것을 담기보다 관심사별로 atom을 분리하세요.
- 리렌더링 범위를 최소화하여 성능을 확보합니다.
읽기/쓰기 훅 적절히 분리
- 읽기 전용은 useRecoilValue, 쓰기 전용은 useSetRecoilState로 구분하면 불필요한 리렌더를 줄일 수 있습니다.
전역 상태 vs 서버 상태 구분
- 서버 상태(fetch/캐싱/리패칭)는 React Query 같은 도구가 더 적합한 경우가 많습니다.
- 반면 클라이언트 전역 UI 상태(모달, 필터, 권한 플래그 등)는 Recoil이 간결합니다.
언제 Recoil을 쓰면 좋은가?
- 작은~중간 규모 앱에서 빠르게 전역 상태를 도입하고 싶을 때
- 파생 상태가 많고, 계산/조합으로 값이 자주 만들어질 때
- Redux의 보일러플레이트가 부담스럽고, Context로는 리렌더링 관리가 어려울 때
- 실무 포인트: 서버 상태는 React Query, 클라이언트 전역/파생 상태는 Recoil 혼합 전략이 가장 현실적입니다.
'개발 공부 > React' 카테고리의 다른 글
| useOptimistic (0) | 2025.08.28 |
|---|---|
| Zustand (1) (2) | 2025.08.17 |
| React Query, Redux Toolkit Query (0) | 2025.06.22 |
| TanStack Query (0) | 2025.05.22 |
| Next.js - 동적 라우팅 (0) | 2025.05.14 |