CRUD на React я собираю руками чаще, чем ставлю готовые библиотеки вроде React-admin или Refine - на большинстве проектов клиенту нужна не полноценная админ-панель со своей экосистемой плагинов, а десяток форм и таблиц с созданием, чтением, редактированием и удалением записей. Ставить ради этого фреймворк с собственным роутингом, темами и провайдерами данных - оверинжиниринг, который потом сложнее поддерживать, чем написанный вручную хук на 80 строк.
Что такое CRUD-паттерн в React-приложении на практике
CRUD - это Create, Read, Update, Delete, четыре операции, вокруг которых крутится 90% интерфейсов для работы с данными: список товаров, заказы, пользователи, задачи. В React это не встроенная концепция - React рисует UI по состоянию, а CRUD-логику вы собираете сами из трёх частей: хранилище состояния (список записей, флаги загрузки, ошибки), функции для похода в API и компоненты, которые дергают эти функции по действиям пользователя.
Проблема в том, что если размазать эту логику по компонентам - в каждом write useState и useEffect с fetch - очень быстро получается дублирование и рассинхрон состояния. Я обычно выношу всё в один кастомный хук на сущность (useProducts, useOrders) и дальше компоненты дергают только его.
Архитектура: как я организую состояние без Redux
Для CRUD с одной сущностью useReducer подходит лучше useState - операций мало, но они меняют состояние по разным правилам, и явный набор экшенов читается понятнее, чем пять отдельных сеттеров.
function crudReducer(state, action) {
switch (action.type) {
case 'FETCH_START':
return { ...state, loading: true, error: null };
case 'FETCH_SUCCESS':
return { ...state, loading: false, items: action.payload };
case 'FETCH_ERROR':
return { ...state, loading: false, error: action.payload };
case 'ADD_ITEM':
return { ...state, items: [...state.items, action.payload] };
case 'UPDATE_ITEM':
return {
...state,
items: state.items.map((item) =>
item.id === action.payload.id ? action.payload : item
),
};
case 'REMOVE_ITEM':
return {
...state,
items: state.items.filter((item) => item.id !== action.payload),
};
default:
return state;
}
}
const initialState = { items: [], loading: false, error: null };
Этот редьюсер не завязан на конкретный эндпоинт - я переиспользую его почти без изменений между проектами, меняется только слой запросов к API.
Create и Read: загрузка списка и добавление записи
Запросы выношу в отдельный хук, который принимает базовый URL сущности. На чтении важно гасить гонку запросов - если пользователь быстро переключает фильтры, старый ответ не должен затирать новый. Для этого использую AbortController.
function useEntityCrud(endpoint) {
const [state, dispatch] = useReducer(crudReducer, initialState);
const fetchItems = useCallback(async (signal) => {
dispatch({ type: 'FETCH_START' });
try {
const res = await fetch(endpoint, { signal });
if (!res.ok) throw new Error(`Ошибка загрузки: ${res.status}`);
const data = await res.json();
dispatch({ type: 'FETCH_SUCCESS', payload: data });
} catch (err) {
if (err.name !== 'AbortError') {
dispatch({ type: 'FETCH_ERROR', payload: err.message });
}
}
}, [endpoint]);
const createItem = useCallback(async (payload) => {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error('Не удалось создать запись');
const created = await res.json();
dispatch({ type: 'ADD_ITEM', payload: created });
return created;
}, [endpoint]);
return { ...state, fetchItems, createItem, dispatch };
}
Вызов fetchItems с AbortController в useEffect выглядит так: создаём контроллер, передаём signal, а в cleanup-функции вызываем controller.abort() - при размонтировании компонента или смене зависимостей старый запрос обрывается сам, и обработчик в catch просто игнорирует AbortError.
Бесплатный материал
🎁 Полезный скрипт в подарок
Подпишитесь на Telegram - пришлю готовый скрипт по этой теме.
Без спама. Отписка в 1 клик.
Update и Delete с оптимистичным интерфейсом
На Update и Delete главный вопрос - ждать ли ответ сервера, прежде чем менять UI. Для админок, где на редактирование заказа уходит 300-600 мс, я обычно делаю оптимистичное обновление: сразу меняю состояние в интерфейсе, а если запрос упал - откатываю.
const updateItem = useCallback(async (id, patch) => {
const prevItems = state.items;
const optimistic = state.items.map((item) =>
item.id === id ? { ...item, ...patch } : item
);
dispatch({ type: 'FETCH_SUCCESS', payload: optimistic });
try {
const res = await fetch(`${endpoint}/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
if (!res.ok) throw new Error('Не удалось сохранить изменения');
const updated = await res.json();
dispatch({ type: 'UPDATE_ITEM', payload: updated });
} catch (err) {
dispatch({ type: 'FETCH_SUCCESS', payload: prevItems });
dispatch({ type: 'FETCH_ERROR', payload: err.message });
}
}, [endpoint, state.items]);
На удалении та же логика: убираем запись из state.items сразу по клику, параллельно отправляем DELETE, при ошибке возвращаем запись обратно и показываем тост. На админке для интернет-магазина, где я собирал такой CRUD для заказов, статусы доставки подтягивались отдельным запросом к СДЭК - их я не трогал оптимистично, только после подтверждения от API, потому что откатывать статус доставки в интерфейсе неприятнее, чем подождать лишние полсекунды.
Обработка ошибок, загрузки и гонок запросов
Три вещи, на которых чаще всего спотыкаются в самописном CRUD:
- Общий loading на весь список вместо loading по конкретной операции - из-за этого при сохранении одной строки блокируется вся таблица.
- Отсутствие дебаунса на поиск и фильтры - на списке из 500+ строк каждый символ в поле поиска бьёт по API.
- Игнорирование гонки запросов - быстро переключили вкладку туда-обратно, и в состоянии оказались данные от предыдущего запроса, который пришёл позже.
Под разные операции лучше завести отдельные флаги: state.loading для первичной загрузки списка и локальный isSaving/isDeleting на уровне строки таблицы, который живёт в самом компоненте, а не в общем редьюсере. Это разгружает reducer и не тянет за собой лишние ре-рендеры всей таблицы при сохранении одной ячейки.
Для дебаунса на поиске хватает setTimeout на 300-400 мс в useEffect с очисткой таймера при каждом новом вводе - сторонняя библиотека тут не нужна.
Свой CRUD или готовая библиотека: когда что выбирать
Я сравнивал этот подход с React-admin, Refine и связкой Redux Toolkit Query на нескольких проектах - вот к чему пришёл:
| Критерий | Свой хук на useReducer | React-admin / Refine | RTK Query |
|---|---|---|---|
| Время старта | 1-2 дня на первую сущность | несколько часов, но нужно освоить конвенции | 1 день + настройка store |
| Размер бандла | минимальный | +150-300 КБ | +30-40 КБ поверх Redux |
| Кастомная вёрстка | полная свобода | ограничена компонентами фреймворка | не касается UI |
| Кэширование и инвалидация | пишете сами | встроено | встроено, из коробки |
| Подходит для | 2-10 сущностей, нестандартный UI | классическая админка с типовыми таблицами | крупное приложение с общим стейтом |
Если сущностей больше 15-20 и почти все таблицы однотипные - готовый фреймворк сэкономит время. Если вёрстка кастомная, а сущностей немного, самописный хук выигрывает по контролю и весу бандла. Когда клиенту нужна полноценная админ-панель под конкретные бизнес-процессы, а не типовая CRUD-таблица, я обычно веду такие проекты как отдельную разработку CRM или админ-панели на React - там паттерн из этой статьи ложится в основу, но обрастает ролями, правами доступа и кастомными виджетами.
Готовые заготовки этого хука - с типами под TypeScript и обработкой ошибок - можно посмотреть в библиотеке готовых скриптов, если не хочется писать редьюсер с нуля под каждый проект.
Кастомный CRM/админ-интерфейс
Админка / CRM
от 100 000 ₽
Подробнее →Частые вопросы
Нужен ли Redux для CRUD на React в 2026 году
Для одной-двух сущностей - нет, useReducer в кастомном хуке справляется без внешних зависимостей. Redux или Zustand имеет смысл подключать, когда состояние сущностей нужно расшаривать между далёкими друг от друга частями дерева компонентов или когда сущностей становится больше 10-15 и дублирование логики между хуками начинает раздражать.
Чем самописный CRUD хуже React-admin или Refine
Он не хуже - он под другую задачу. Готовые фреймворки дают кэширование, инвалидацию, готовые формы и таблицы из коробки, но взамен диктуют структуру данных и вёрстку. Если дизайн нестандартный или сущностей немного, свой хук занимает меньше места в бандле и не тянет чужих абстракций, которые потом придётся обходить.
Как защититься от гонки запросов при быстром переключении фильтров
AbortController - самый надёжный способ: при каждом новом запросе отменяете предыдущий через signal, а в catch отдельно проверяете err.name !== ‘AbortError’, чтобы не показывать пользователю ложную ошибку. Второй вариант - сравнивать id запроса перед записью в state, но AbortController чище и не требует ручного счётчика.
Сколько стоит заказать готовую CRUD-админку на React
Если нужна не самописная заготовка, а рабочая CRM или админ-панель под конкретные бизнес-процессы - с ролями, фильтрами, интеграцией с бэкендом - у меня разработка CRM/админ-панели на React начинается от 100 000 ₽, конкретная сумма зависит от числа сущностей и сложности прав доступа.