open atlas
↑ К треку
Паттерны React RXP · 15 · 02

Капстоун II: инкрементальный рефакторинг

Выполни инвентарь из части 1 по одному изменению за раз: сначала P0-баги (эффект производного состояния, ключи), затем раздели контекст и стабилизируй значение, разбери на композицию + кастомные хуки и оберни async в Suspense + Error Boundary — без переписывания целиком.

RXP Senior ◷ 21 min
Уровень
ОсновыJuniorMiddleSenior

Часть 1 оставила тебя с ранжированным инвентарём фичи Dashboard: P0 — состояние, выведенное через эффект, P0 — index-ключи, P1 — раздутый контекст, P1 — божественный компонент, P2 — проброс пропсов. Соблазн теперь — открыть файл, удалить всё и переписать «как надо» за один присест. Это и есть senior-ловушка. Переписывание целиком выбрасывает единственное, что купил тебе инвентарь, — последовательность маленьких, доказуемых изменений — и заменяет её 2000-строчным диффом, который никто не сможет отревьюить, и фичей, сломанной на неделю.

Дисциплина в обратном: один запах — один коммит. Каждый шаг закрывает названный пункт списка, держит приложение зелёным и достаточно мал, чтобы откатить его в одиночку, если он даст регресс. Этот урок проходит инвентарь в порядке влияния, исправляя каждое давление ровно тем паттерном, который оно просит, — и останавливаясь на этом, чтобы ты не вернул то самое переусложнение, которое тебя наняли убрать.

Цель

После этого урока ты можешь взять инвентарь из части 1 и выполнить его как инкрементальный рефакторинг: сначала исправить два P0-бага корректности (удалить эффект производного состояния, заменить index-ключи на стабильные id), затем разделить раздутый контекст и стабилизировать его значение, разобрать божественный компонент через композицию плюс вынесенные кастомные хуки, колоцировать или поднять состояние на нужный уровень и обернуть async-части в границу <Suspense> под Error Boundary — каждое как отдельное, зелёное, откатываемое изменение, и каждое оправдано конкретным давлением, которое оно снимает, а не применённое огулом.

1

Сначала исправь P0-баги, потому что корректность не может ждать за редизайном — и каждый из них крошечный дифф. Эффект производного состояния отдаёт устаревшие данные; index-ключи портят состояние по строкам. Ни один из них не требует разбивки компонента, так что делай их до того, как трогать структуру. Удаление эффекта заодно удаляет useState, цикл «рендер-после-рендера» и грабли с массивом зависимостей — одним движением.

// before — P0: производное состояние через эффект (устаревает на кадр, хрупко по зависимостям)
const [filtered, setFiltered] = useState<Row[]>([]);
useEffect(() => {
  setFiltered(data.filter((r) => r.name.includes(query)));
}, [data, query]);

// after — выводим во время рендера: ни эффекта, ни второго состояния, никогда не устаревает
const filtered = data.filter((r) => r.name.includes(query));
// before — P0: index-ключ на переупорядочиваемом списке с состоянием
{rows.map((row, i) => <RowDetail key={i} row={row} />)}
// after — идентичность следует за строкой, а не за слотом
{rows.map((row) => <RowDetail key={row.id} row={row} />)}

Два коммита, два закрытых P0-пункта, ноль новых абстракций. Фича теперь корректна до того, как стала красивой, — и если что-то даст регресс, откат — одна строка.

2

Раздели раздутый контекст и стабилизируй его значение — но только ту часть, которую оправдывает давление. P1-раздутый контекст перерисовывает каждого потребителя на каждое нажатие клавиши, потому что query, selectedId, theme и data едут в одном значении провайдера. Дели по частоте обновлений, а не ради опрятности: волатильные query/selectedId идут в один контекст, стабильные theme/data — в другой, чтобы потребитель темы больше не перерисовывался, когда ты печатаешь. И оберни каждое значение в useMemo, чтобы новый объектный литерал на каждом рендере не сводил разделение на нет.

// after — два контекста, разделённых по волатильности, каждое значение стабилизировано
const FilterContext = createContext<FilterCtx | null>(null);
const DataContext = createContext<DataCtx | null>(null);

function DashboardProviders({ children }: { children: React.ReactNode }) {
  const [query, setQuery] = useState("");
  const [selectedId, setSelectedId] = useState<string | null>(null);
  const filter = useMemo(() => ({ query, setQuery, selectedId, setSelectedId }),
    [query, selectedId]);              // волатильное — меняется на каждое нажатие
  const data = useMemo(() => ({ rows, theme }), [rows, theme]); // стабильное
  return (
    <DataContext.Provider value={data}>
      <FilterContext.Provider value={filter}>{children}</FilterContext.Provider>
    </DataContext.Provider>
  );
}

Не дроби на пять контекстов потому, что можешь, — дроби, пока высокочастотные обновления не перестанут трогать низкочастотных потребителей. Там кончается давление — там кончается изменение.

3

Разбери божественный компонент через композицию + вынесенные кастомные хуки — обязанность за обязанностью, а не одним переписыванием. P1-божественный компонент владеет загрузкой, фильтрацией, сортировкой, пагинацией, выбором и рендерингом. Вытягивай по одной обязанности за раз: вынеси жизненный цикл данных в useDashboardData() (кастомный хук), затем нарежь рендеринг на <Toolbar>, <DataTable> и <Detail>. После каждого выноса приложение по-прежнему работает — ты перемещаешь строки в названный дом, а не меняешь поведение.

// выносим обязанность данных в хук (тестируемый, переиспользуемый, одна задача)
function useDashboardData(query: string, sortKey: keyof Row) {
  const rows = use(fetchRows());            // React 19: читаем промис
  return useMemo(
    () => sortRows(rows.filter((r) => r.name.includes(query)), sortKey),
    [rows, query, sortKey],
  );
}

// божественный компонент сжимается до композиции: каждый ребёнок владеет одной обязанностью
function Dashboard() {
  return (
    <DashboardProviders>
      <Toolbar />
      <DataTable />
      <Detail />
    </DashboardProviders>
  );
}

Композиция заодно бесплатно растворяет P2-проброс пропсов: как только RowActions читает selectedId из FilterContext (или сидит прямым ребёнком компонента, который им владеет), четыре транзитных перехода исчезают. Ты не добавил паттерн для проброса — ты позволил структурному исправлению поглотить его.

4

Колоцируй или подними каждый кусок состояния на уровень, который им владеет, — затем оберни async-границу в Suspense под Error Boundary. С разделёнными обязанностями состояние находит свой уровень: флаг open/closed строки колоцируется внутри <RowDetail> (никого над ним он не волнует), тогда как query/selectedId остаются поднятыми в провайдер, потому что их разделяют два сиблинга. Состояние, которое никто не разделяет, не место в провайдере. Наконец, загрузка данных — единственная async-граница, так что она получает fallback <Suspense> и Error Boundary над собой — колоцированные, потому что граница без UI ошибки — это половина границы.

// состояние на нужном уровне + async-граница сделана явной
function DataTable() {
  return (
    <ErrorBoundary fallback={<TableError />}>
      <Suspense fallback={<TableSkeleton />}>
        <DataRows />          {/* useDashboardData() приостанавливается здесь */}
      </Suspense>
    </ErrorBoundary>
  );
}

function RowDetail({ row }: { row: Row }) {
  const [open, setOpen] = useState(false); // колоцировано — волнует только эту строку
  return <div onClick={() => setOpen((o) => !o)}>{open && <Body row={row} />}</div>;
}

Пять коммитов спустя инвентарь закрыт: каждый P0-баг исчез, контекст разделён, божественный компонент составлен, состояние на своём уровне, async-граница ограничена — и дифф читается как последовательность, а не как взрыв.

Разбор примера

Один шаг целиком — превращение P0-эффекта производного состояния в зелёный изолированный коммит. Это весь метод в миниатюре: один названный запах, наименьший дифф, который его закрывает, и доказательство, что он исчез.

// === commit: "fix(dashboard): derive filtered rows in render, drop the effect" ===
//
// before — P0-пункт: состояние, выведенное через эффект, латентный баг устаревания
-  const [filtered, setFiltered] = useState<Row[]>([]);
-  useEffect(() => {
-    setFiltered(data.filter((r) => r.name.includes(query)));
-  }, [data, query]);                         // пропусти зависимость → навсегда устаревшее
-  // ...рендер использует `filtered`
//
// after — выводим во время рендера: корректно на первом кадре, без лишнего состояния
+  const filtered = data.filter((r) => r.name.includes(query));
+  // ...рендер использует `filtered` без изменений

Почему это полный шаг, а не полумера: изменение чисто локально (рендер ниже по-прежнему читает filtered), оно удаляет больше, чем добавляет, и «доказательство» пишет себя само — инвентарный «P0, латентный баг устаревания» теперь ненаблюдаем, потому что больше нет кадра, где filtered расходится с data + query. Memo пока не нужен; .filter по типичному списку дёшев, а добавление useMemo здесь спекулятивно было бы ровно тем переусложнением, от которого предостерегает этот капстоун. Если профилирование позже покажет, что список огромен и родитель часто перерисовывается, тогда useMemo заслужит своё место — измеренное давление, а не упреждающая церемония.

Сравни с альтернативой «всё сразу»: переписывание Dashboard от и до похоронило бы это однострочное исправление корректности внутри 500-строчного диффа, сделав невозможным отревьюить исправление в одиночку, невозможным откатить только его, если новая структура даст регресс, и невозможным указать ревьюеру на «этот коммит исправил баг устаревания». Инкрементальный путь держит каждое утверждение фальсифицируемым.

Почему это работает

Почему исправлять P0-баги до структурных запахов, когда разбивка компонента ощущается «настоящим» рефакторингом? Потому что корректность отгружается пользователям, а структура отгружается разработчикам. Эффект производного состояния может отрендерить неверные данные сегодня; божественный компонент лишь делает завтрашнее изменение медленнее. Сделать структуру первой ещё и рискует перенести баг устаревания в новый файл и отгрузить его «выглядящим отрефакторенным» — ошибка из части 1. Упорядочивай по влиянию, и самые рискованные однострочные исправления приземлятся первыми, пока дифф ещё достаточно мал, чтобы его рассмотреть.

Частая ошибка

Режим отказа, ради предотвращения которого существует этот урок, имеет два лица. Первое — переписывание целиком: удалить фичу и пересобрать её, что даёт неотревьюиваемый дифф, неделю поломки и никакого доказательства по каждому запаху. Второе тоньше — применение паттернов там, где давление их не оправдало: оборачивать каждое производное значение в useMemo, дробить контекст на пять осколков, делать compound-component API для одноразовой строки, добавлять Error Boundary вокруг кода, который не может бросить. Это возвращает то переусложнение, которое тебя наняли убрать. Правило, нарушаемое обоими лицами, одно: сопоставляй каждый паттерн с названным, ранжированным давлением из инвентаря, а когда в инвентаре нет пункта — не пиши кода.

Проверь себя
Викторина

Ты выполняешь инвентарь из части 1. Какая последовательность — senior-ход, и почему?

Итог

Инкрементальный рефакторинг проходит инвентарь из части 1 по одному изменению за раз, каждое — отдельный, зелёный, откатываемый коммит, сопоставленный с названным давлением. Упорядочивай по влиянию: два P0-бага корректности идут первыми — удали эффект производного состояния (выводи во время рендера) и замени index-ключи на стабильные id, — потому что это крошечные диффы с наивысшими ставками и не могут ждать за редизайном. Затем P1-запахи рендера: раздели раздутый контекст по частоте обновлений и useMemo его значения, чтобы высокочастотные обновления перестали трогать низкочастотных потребителей, и разбери божественный компонент через композицию + вынесенные кастомные хуки, что бесплатно растворяет P2-проброс пропсов. С разделёнными обязанностями колоцируй или подними каждый кусок состояния на уровень, который им владеет, и оберни единственную async-границу в <Suspense> под Error Boundary. Два режима отказа — зеркальные отражения: переписывание целиком (неотревьюиваемое, недоказуемое, сломанное на неделю) и применение паттернов без давления (возврат переусложнения). Оба нарушают одно правило — сопоставляй каждый паттерн с ранжированным пунктом, а где инвентарь молчит — не пиши кода.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 4 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.