Настройка State Management (Recoil) для React-приложения
Recoil — атомарный state manager от Meta. Атомы и селекторы образуют граф состояния, который React использует для точечных ре-рендеров. Нативно поддерживает Concurrent Mode, Suspense и асинхронные селекторы.
Подходит для крупных React-приложений с разветвлённым состоянием, где важна гранулярность подписок и встроенная поддержка async без дополнительных библиотек.
Что входит в работу
Инициализация Recoil в проекте, проектирование атомов и селекторов, настройка async-данных через Suspense, типизация, devtools, персистентность, атомные семейства для списков.
Установка
npm install recoil
# devtools
npm install recoil-devtools
Оборачиваем приложение в RecoilRoot:
import { RecoilRoot } from 'recoil'
function App() {
return (
<RecoilRoot>
<Router />
</RecoilRoot>
)
}
Атомы
import { atom } from 'recoil'
export const authTokenAtom = atom<string | null>({
key: 'authToken',
default: null,
})
export const cartItemsAtom = atom<CartItem[]>({
key: 'cartItems',
default: [],
})
export const sidebarOpenAtom = atom<boolean>({
key: 'sidebarOpen',
default: false,
})
Ключи атомов — уникальные строки во всём приложении. Удобно использовать префикс по домену: 'cart/items', 'auth/token'.
Селекторы
import { selector } from 'recoil'
export const cartTotalSelector = selector<number>({
key: 'cartTotal',
get: ({ get }) => {
const items = get(cartItemsAtom)
return items.reduce((sum, item) => sum + item.price * item.quantity, 0)
},
})
export const cartCountSelector = selector<number>({
key: 'cartCount',
get: ({ get }) => get(cartItemsAtom).reduce((sum, i) => sum + i.quantity, 0),
})
Селекторы мемоизированы — пересчитываются только при изменении зависимых атомов.
Хуки
import { useRecoilState, useRecoilValue, useSetRecoilState, useResetRecoilState } from 'recoil'
function CartBadge() {
const count = useRecoilValue(cartCountSelector)
return <span>{count}</span>
}
function CartControls() {
const [items, setItems] = useRecoilState(cartItemsAtom)
const reset = useResetRecoilState(cartItemsAtom)
const addItem = (item: CartItem) =>
setItems((prev) => {
const existing = prev.find((i) => i.id === item.id)
if (existing) {
return prev.map((i) => i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i)
}
return [...prev, { ...item, quantity: 1 }]
})
return (
<>
<button onClick={() => addItem(product)}>Добавить</button>
<button onClick={reset}>Очистить корзину</button>
</>
)
}
Async-селекторы и Suspense
import { selector, selectorFamily } from 'recoil'
// selectorFamily — параметризованный селектор
export const productSelector = selectorFamily<Product, number>({
key: 'product',
get: (productId) => async () => {
const res = await fetch(`/api/products/${productId}`)
if (!res.ok) throw new Error(`Product ${productId} not found`)
return res.json()
},
})
export const currentUserSelector = selector<User | null>({
key: 'currentUser',
get: async ({ get }) => {
const token = get(authTokenAtom)
if (!token) return null
const res = await fetch('/api/me', {
headers: { Authorization: `Bearer ${token}` },
})
return res.json()
},
})
function ProductCard({ id }: { id: number }) {
const product = useRecoilValue(productSelector(id)) // suspend
return <div>{product.name} — {product.price} ₽</div>
}
function ProductPage({ id }: { id: number }) {
return (
<Suspense fallback={<Skeleton />}>
<ErrorBoundary fallback={<NotFound />}>
<ProductCard id={id} />
</ErrorBoundary>
</Suspense>
)
}
AtomFamily — атомы для списков
import { atomFamily } from 'recoil'
export const todoAtomFamily = atomFamily<Todo, number>({
key: 'todo',
default: (id) => ({ id, text: '', done: false }),
})
// у каждого todo-айтема — независимый атом
function TodoItem({ id }: { id: number }) {
const [todo, setTodo] = useRecoilState(todoAtomFamily(id))
return (
<label>
<input
type="checkbox"
checked={todo.done}
onChange={(e) => setTodo((t) => ({ ...t, done: e.target.checked }))}
/>
{todo.text}
</label>
)
}
Персистентность через effects
import { AtomEffect } from 'recoil'
function localStorageEffect<T>(key: string): AtomEffect<T> {
return ({ setSelf, onSet }) => {
const saved = localStorage.getItem(key)
if (saved !== null) {
try { setSelf(JSON.parse(saved)) } catch {}
}
onSet((newValue, _, isReset) => {
if (isReset) {
localStorage.removeItem(key)
} else {
localStorage.setItem(key, JSON.stringify(newValue))
}
})
}
}
export const themeAtom = atom<'light' | 'dark'>({
key: 'theme',
default: 'light',
effects: [localStorageEffect('theme')],
})
Тестирование
import { renderRecoilHook } from 'recoil-test-utils'
test('cartTotal вычисляется правильно', () => {
const { result } = renderRecoilHook(
() => useRecoilValue(cartTotalSelector),
{
initializeState: ({ set }) => {
set(cartItemsAtom, [
{ id: '1', price: 100, quantity: 2 },
{ id: '2', price: 50, quantity: 1 },
])
},
}
)
expect(result.current).toBe(250)
})
Структура файлов
src/
recoil/
atoms/
auth.ts
cart.ts
ui.ts
selectors/
cart.selectors.ts
user.selectors.ts
families/
products.family.ts
todos.family.ts
Что делаем
Настраиваем RecoilRoot, проектируем атомный граф под задачи проекта, реализуем async-данные через selectorFamily с Suspense, подключаем atom effects для персистентности, покрываем тестами ключевые селекторы.
Срок: 1–2 дня.







