Разработка · 6 мин чтения

CRUD на React без готового фреймворка: разбор паттерна

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 ₽, конкретная сумма зависит от числа сущностей и сложности прав доступа.

Есть задача?

Обсудим в мессенджере

Расскажите, что нужно сделать — отвечу в течение 4 часов в рабочее время. Первая консультация бесплатно.

Самозанятый Калинкин Н. А. · работаю с физлицами и юрлицами

Продолжая пользование настоящим сайтом Вы выражаете своё согласие на обработку Ваших персональных данных (файлов куки) с использованием Yandex.Metrika.
Понятно