Как делать11 октября 2026 г.8 мин чтения
ReactШаг 4 из 8

useEffect: синхронизация с внешним миром и когда он не нужен

Коротко

useEffect синхронизирует компонент с системой вне React — таймером, подпиской, сетью, API браузера: код эффекта выполняется после того, как React обновил экран, а функция очистки отменяет его перед следующим запуском и при удалении компонента. Зависимости не выбирают: в массив попадают все props, состояние и переменные компонента, которые читает эффект, и это проверяет линтер. Если внешней системы нет — данные можно вычислить при рендере или действие вызвано кликом, — эффект не нужен.

Глубокий разборУровень: СреднийХуки React

После материала вы сможете

  • написать эффект с правильными зависимостями и функцией очистки для таймера и запроса с AbortController
  • объяснить двойной запуск эффекта в StrictMode и проверить, что эффект его выдерживает
  • найти и устранить гонку запросов при загрузке данных и проверить это тестом с поддельными таймерами
  • заменить лишние эффекты вычислением при рендере, обработчиком события или key

Проверено на

React 19.3.0TypeScript 6.0.3TypeScript 7.0.2Vite 8.3.4Vitest 5.0.3@testing-library/react 16.3.3ESLint 10.12.0eslint-plugin-react-hooks 7.1.1Node.js 24 LTS (24.18.0)

Сверено с документацией: 11 октября 2026 г.

Содержание
  1. 01Эффект — синхронизация с внешней системой
  2. 02Массив зависимостей и почему линтер прав
  3. 03Очистка: таймеры, подписки, AbortController
  4. 04StrictMode и двойной запуск в разработке
  5. 05Загрузка данных: гонки и когда брать библиотеку
  6. 06Вам не нужен эффект: производные данные и обработчики
  7. 07Чек-лист перед useEffect, тесты и практика

Эффект — синхронизация с внешней системой

В React-компоненте код живёт в трёх местах. Отрисовка только вычисляет разметку из props и состояния. Обработчики событий делают что-то в ответ на конкретное действие пользователя: клик, ввод, отправку формы. Эффекты — третье место: документация описывает их как код, который выполняется после отрисовки, чтобы синхронизировать компонент с системой вне React.

Тело компонента (рендер)

Когда выполняется
при каждой отрисовке; должен быть чистым
Пример
посчитать итог корзины, отфильтровать список

Обработчик события

Когда выполняется
в ответ на конкретное действие
Пример
отправить заявку по клику «Записаться»

Эффект

Когда выполняется
после того как React обновил экран, — потому что компонент показан
Пример
запустить таймер брони, подписаться на события, загрузить данные для текущего запроса

Документация React называет эффекты «запасным выходом» (escape hatch): ими пользуются, когда нужно выйти за пределы React и нет встроенного решения. Отсюда главный вопрос всего урока — есть ли здесь внешняя система? Если её нет, эффект почти наверняка лишний.

Урок продолжает траекторию: после useState соберём для салона красоты таймер брони и поиск услуг с загрузкой с сервера, проверим их тестами и разберём четыре эффекта, которые стоит удалить. Нужны знания состояния и useState, а также Promise и async/await.

bash
npm create vite@latest booking-search -- --template react-ts
cd booking-search
npm install
npm install -D vitest jsdom @testing-library/react @testing-library/dom
npm install -D eslint eslint-plugin-react-hooks typescript-eslint
Шаблон Vite использует линтер oxlint; для правил хуков React мы добавили ESLint с официальным плагином.

Массив зависимостей и почему линтер прав

Второй аргумент useEffect — массив зависимостей. React сравнивает каждую зависимость с прошлым значением через Object.is и перезапускает эффект, только если что-то изменилось.

useEffect(fn)

Когда выполняется эффект
после каждой отрисовки

useEffect(fn, [])

Когда выполняется эффект
один раз при появлении компонента (в разработке под StrictMode — с одним дополнительным циклом, см. ниже)

useEffect(fn, [a, b])

Когда выполняется эффект
при появлении и после отрисовок, в которых изменились a или b

Главное правило из документации: зависимости не выбирают. Реактивные значения — props, состояние и все переменные, объявленные в теле компонента, — могут измениться при следующей отрисовке. Если эффект их читает, они обязаны быть в массиве. Иначе эффект продолжит работать со старыми значениями. Проверку делает правило exhaustive-deps из eslint-plugin-react-hooks; документация прямо говорит, что все его замечания обоснованы, а подавлять линтер не стоит.

eslint.config.js
import reactHooks from 'eslint-plugin-react-hooks'
import { defineConfig } from 'eslint/config'
import tseslint from 'typescript-eslint'

export default defineConfig([
  {
    files: ['src/**/*.{ts,tsx}'],
    languageOptions: { parser: tseslint.parser },
  },
  reactHooks.configs.flat.recommended,
])
Рекомендованный набор правил плагина; парсер typescript-eslint нужен, чтобы ESLint читал TSX.

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

Очистка: таймеры, подписки, AbortController

Эффект может вернуть функцию очистки. React вызывает её перед каждым повторным запуском эффекта и один раз при удалении компонента со страницы. Очистка — зеркало эффекта: запустили интервал — остановите, подписались — отпишитесь, отправили запрос — отмените.

src/components/HoldTimer.tsx
import { useEffect, useState } from 'react'

function format(totalSeconds: number) {
  const minutes = Math.floor(totalSeconds / 60)
  const seconds = String(totalSeconds % 60).padStart(2, '0')
  return `${minutes}:${seconds}`
}

export function HoldTimer({ initialSeconds }: { initialSeconds: number }) {
  const [left, setLeft] = useState(initialSeconds)
  const expired = left <= 0

  useEffect(() => {
    if (expired) return // синхронизировать нечего: таймер больше не нужен

    const id = setInterval(() => {
      setLeft((s) => s - 1)
    }, 1000)

    return () => clearInterval(id) // очистка: при размонтировании и перед новым запуском
  }, [expired])

  return <p role="status">{expired ? 'Бронь истекла' : `Время держится ещё ${format(left)}`}</p>
}
Таймер удержания записи: слот держится, пока клиент оформляет визит.
  • Внешняя система здесь — интервал браузера. Эффект запускает его, очистка останавливает.
  • Зависимость — expired, а не left. Если поставить left, интервал пересоздавался бы каждую секунду. Внутри интервала используется функция обновления setLeft((s) => s - 1), поэтому текущее left эффекту читать не нужно.
  • Когда время выходит, expired становится true: React вызывает очистку старого эффекта, а новый запуск сразу выходит — интервалов больше нет. Это проверяет тест через vi.getTimerCount().
  • initialSeconds по соглашению из документации назван с префиксом initial: prop задаёт только начальное значение, и его дальнейшие изменения игнорируются.

setInterval, setTimeout

Что делает очистка
clearInterval, clearTimeout

addEventListener

Что делает очистка
removeEventListener с той же функцией

Подключение к чату, WebSocket

Что делает очистка
disconnect, close

fetch с AbortController

Что делает очистка
controller.abort() — ответ на устаревший запрос не придёт

Анимация, сторонний виджет

Что делает очистка
остановка, destroy или возврат в исходное состояние

StrictMode и двойной запуск в разработке

Шаблон Vite оборачивает приложение в StrictMode. По документации, в режиме разработки он делает дополнительные проверки: повторную отрисовку компонентов, повторный запуск эффектов и проверку устаревших API. Все они работают только при разработке и не влияют на продакшен-сборку.

src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
)
Файл из шаблона без изменений.

Для эффектов это значит: React монтирует компонент, сразу размонтирует и монтирует снова. Вы видите цепочку запуск → очистка → запуск. Это не ошибка, а проверка: если эффект без правильной очистки, двойной запуск это покажет. Тест подтверждает поведение нашего таймера:

src/effects.test.tsx
  it('в StrictMode при разработке: запуск → очистка → запуск', () => {
    const setSpy = vi.spyOn(globalThis, 'setInterval')
    const clearSpy = vi.spyOn(globalThis, 'clearInterval')
    render(
      <StrictMode>
        <HoldTimer initialSeconds={60} />
      </StrictMode>,
    )
    expect(setSpy).toHaveBeenCalledTimes(2)
    expect(clearSpy).toHaveBeenCalledTimes(1)
    expect(vi.getTimerCount()).toBe(1) // в итоге работает ровно один интервал
  })
Фрагмент файла тестов; полный файл — в последнем разделе.

Документация формулирует правило: пользователь не должен отличать однократный запуск эффекта (как в продакшене) от цепочки запуск → очистка → запуск (как при разработке). Поэтому правильный вопрос — не «как запустить эффект один раз», а «как исправить эффект, чтобы он работал после повторного монтирования». Ответ почти всегда — дописать очистку.

Загрузка данных: гонки и когда брать библиотеку

Поиск услуг — честный случай для эффекта: результаты нужно синхронизировать с текущим текстом запроса, и неважно, как именно он изменился. Начнём с наивной версии, которая встречается чаще всего:

src/bad/NaiveSearch.tsx
import { useEffect, useState } from 'react'
import { searchServices, type Service } from '../api/services.ts'

export function NaiveSearch() {
  const [query, setQuery] = useState('')
  const [services, setServices] = useState<Service[]>([])

  useEffect(() => {
    if (query === '') return
    // 🔴 нет очистки: кто ответил последним, тот и победил
    searchServices(query, new AbortController().signal).then(setServices)
  }, [query])

  return (
    <section>
      <label>
        Поиск услуги <input value={query} onChange={(e) => setQuery(e.target.value)} />
      </label>
      <ul>
        {services.map((service) => (
          <li key={service.id}>{service.title}</li>
        ))}
      </ul>
    </section>
  )
}
Так нельзя: нет очистки.

Пользователь печатает «ма», затем «маникюр». Уходят два запроса, и ответы могут прийти в любом порядке. Мы смоделировали в тесте медленный первый ответ (500 мс) и быстрый второй (100 мс): после обоих на экране остался список для «ма», хотя в поле написано «маникюр». Это гонка (race condition), и линтер её не видит — код формально корректен.

src/api/services.ts
export interface Service {
  id: string
  title: string
}

export async function searchServices(query: string, signal: AbortSignal): Promise<Service[]> {
  const response = await fetch(`/api/services?q=${encodeURIComponent(query)}`, { signal })
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`)
  }
  return (await response.json()) as Service[]
}
src/components/ServiceSearch.tsx
import { useEffect, useState } from 'react'
import { searchServices, type Service } from '../api/services.ts'

type Result =
  | { query: string; services: Service[] }
  | { query: string; error: string }

export function ServiceSearch() {
  const [query, setQuery] = useState('')
  const [result, setResult] = useState<Result | null>(null)

  useEffect(() => {
    if (query === '') return

    const controller = new AbortController()

    searchServices(query, controller.signal)
      .then((services) => setResult({ query, services }))
      .catch(() => {
        if (controller.signal.aborted) return // запрос отменён нами — это не ошибка
        setResult({ query, error: 'Не удалось загрузить услуги' })
      })

    return () => controller.abort() // устаревший запрос отменяется
  }, [query])

  // «загрузка» не хранится в состоянии: это ответ не на текущий запрос
  const isLoading = query !== '' && result?.query !== query
  const current = result?.query === query ? result : null

  return (
    <section>
      <label>
        Поиск услуги <input value={query} onChange={(e) => setQuery(e.target.value)} />
      </label>
      {query === '' && <p>Начните вводить название</p>}
      {isLoading && <p>Ищем…</p>}
      {current && 'error' in current && <p role="alert">{current.error}</p>}
      {current && 'services' in current && (
        <ul>
          {current.services.map((service) => (
            <li key={service.id}>{service.title}</li>
          ))}
        </ul>
      )}
    </section>
  )
}
  • Каждый запуск эффекта создаёт свой AbortController, а очистка вызывает abort(). Когда запрос меняется, React сначала очищает прошлый эффект — устаревший fetch отменяется и завершается ошибкой AbortError, которую мы не показываем.
  • В состоянии хранится результат вместе с запросом, на который он пришёл. Флаг загрузки не хранится: «загрузка» — это когда последний результат не для текущего запроса. Так не нужно вызывать setState синхронно в теле эффекта, и интерфейс не может показать чужой ответ.
  • Документация React предлагает и более простой вариант очистки — флаг let ignore = false, который очистка переключает в true, а ответ игнорируется. AbortController дополнительно отменяет сам сетевой запрос.

У загрузки данных в эффектах есть ограничения, которые документация перечисляет прямо: эффекты не выполняются на сервере, легко получить «водопад» последовательных запросов, нет предзагрузки и кеша, много повторяющегося кода. Официальная рекомендация — во фреймворке использовать его встроенный механизм загрузки данных, а без фреймворка — клиентский кеш вроде TanStack Query или SWR. Ручной эффект, как в этом уроке, полезно понимать: именно это библиотеки делают внутри.

Вам не нужен эффект: производные данные и обработчики

Документация выделяет два частых случая, когда эффект не нужен: чтобы преобразовать данные для отрисовки и чтобы обработать действие пользователя. Вот четыре эффекта, которые регулярно встречаются в реальном коде:

src/bad/Unneeded.tsx
import { useEffect, useState } from 'react'
import type { Service } from '../api/services.ts'

// 1. Производные данные через эффект
export function FilteredList({ services, filter }: { services: Service[]; filter: string }) {
  const [visible, setVisible] = useState<Service[]>([])
  useEffect(() => {
    setVisible(services.filter((s) => s.title.includes(filter)))
  }, [services, filter])
  return <p>Найдено: {visible.length}</p>
}

// 2. Действие пользователя через эффект
export function BookButton({ serviceId }: { serviceId: string }) {
  const [booked, setBooked] = useState(false)
  useEffect(() => {
    if (booked) {
      fetch('/api/bookings', { method: 'POST', body: JSON.stringify({ serviceId }) })
    }
  }, [booked, serviceId])
  return <button onClick={() => setBooked(true)}>Записаться</button>
}

// 3. Сброс состояния при смене prop через эффект
export function ReviewForm({ serviceId }: { serviceId: string }) {
  const [text, setText] = useState('')
  useEffect(() => {
    setText('')
  }, [serviceId])
  return <textarea value={text} onChange={(e) => setText(e.target.value)} />
}

// 4. Забытая зависимость
export function PageTitle({ name }: { name: string }) {
  useEffect(() => {
    document.title = `Запись: ${name}`
  }, [])
  return null
}
terminal
$ npx eslint src/bad/Unneeded.tsx

src/bad/Unneeded.tsx
   8:5  error    Error: Calling setState synchronously within an effect can trigger cascading renders
…
>  8 |     setVisible(services.filter((s) => s.title.includes(filter)))
     |     ^^^^^^^^^^ Avoid calling setState() directly within an effect
…  react-hooks/set-state-in-effect
  28:5  error    Error: Calling setState synchronously within an effect can trigger cascading renders
…
> 28 |     setText('')
     |     ^^^^^^^ Avoid calling setState() directly within an effect
…  react-hooks/set-state-in-effect
  37:6  warning  React Hook useEffect has a missing dependency: 'name'. Either include it or remove the dependency array  react-hooks/exhaustive-deps

✖ 3 problems (2 errors, 1 warning)
Вывод ESLint сокращён: пояснения правила и пути опущены.

Линтер поймал три случая из четырёх. Правило set-state-in-effect из рекомендованного набора запрещает синхронный setState в теле эффекта: это лишняя отрисовка каскадом. А BookButton формально корректен, и никакой линтер не скажет, что заявка должна уходить из обработчика. Документация советует спросить себя, почему код должен выполниться: если потому, что компонент показан, — это эффект, если потому, что пользователь нажал кнопку, — обработчик.

src/good/Fixed.tsx
import { useEffect, useState } from 'react'
import type { Service } from '../api/services.ts'

// 1. Производные данные — вычисляем при рендере
export function FilteredList({ services, filter }: { services: Service[]; filter: string }) {
  const visible = services.filter((s) => s.title.includes(filter))
  return <p>Найдено: {visible.length}</p>
}

// 2. Действие пользователя — в обработчике события
export function BookButton({ serviceId }: { serviceId: string }) {
  const [booked, setBooked] = useState(false)

  function handleClick() {
    setBooked(true)
    fetch('/api/bookings', { method: 'POST', body: JSON.stringify({ serviceId }) })
  }

  return (
    <button onClick={handleClick} disabled={booked}>
      {booked ? 'Заявка отправлена' : 'Записаться'}
    </button>
  )
}

// 3. Сброс состояния — через key у родителя
export function ReviewForm() {
  const [text, setText] = useState('')
  return (
    <textarea aria-label="Отзыв" value={text} onChange={(e) => setText(e.target.value)} />
  )
}

export function ReviewSection({ serviceId }: { serviceId: string }) {
  return <ReviewForm key={serviceId} />
}

// 4. Все реактивные значения — в зависимостях
export function PageTitle({ name }: { name: string }) {
  useEffect(() => {
    document.title = `Запись: ${name}`
  }, [name])
  return null
}
Исправленные версии. В BookButton для краткости нет обработки ошибок запроса.

setVisible(filter(...)) в эффекте

Чем заменить
вычисление при рендере: const visible = …
Почему
одна отрисовка вместо двух; дорогие вычисления кешируют useMemo

Запрос после смены флага booked

Чем заменить
запрос в обработчике клика
Почему
это действие пользователя, а не синхронизация; тест подтверждает один запрос на клик даже под StrictMode

setText('') при смене serviceId

Чем заменить
key={serviceId} у компонента
Почему
React считает компоненты с разным key разными и сбрасывает всё их состояние

[] при чтении name

Чем заменить
[name]
Почему
все реактивные значения — в зависимостях

Чек-лист перед useEffect, тесты и практика

  • Есть ли внешняя система — таймер, подписка, сеть, DOM вне React, сторонний виджет? Если нет, эффект не нужен.
  • Можно ли вычислить значение из props и состояния при рендере? Тогда это переменная, а не состояние с эффектом.
  • Код выполняется, потому что пользователь что-то сделал? Тогда его место — в обработчике события.
  • Нужно сбросить всё состояние при смене prop? Передайте компоненту другой key.
  • Что отменяет очистка? У каждого запуска — своя пара «запустить/остановить».
  • Все ли реактивные значения в зависимостях? Предупреждения exhaustive-deps исправляют, а не подавляют.
  • Для загрузки данных: отменяется ли устаревший запрос? Не лучше ли механизм фреймворка или библиотека кеширования?
  • Работает ли эффект после цепочки запуск → очистка → запуск в StrictMode?

Тесты фиксируют поведение, о котором шла речь: отсчёт и остановку таймера, очистку при размонтировании, двойной запуск под StrictMode, отмену устаревшего запроса и обработку ошибки сервера. fetch подменён фейком, который отвечает с заданной задержкой и реагирует на AbortSignal, а время управляется поддельными таймерами Vitest.

src/effects.test.tsx
import { act, fireEvent, render, screen } from '@testing-library/react'
import { StrictMode } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { HoldTimer } from './components/HoldTimer.tsx'
import { ServiceSearch } from './components/ServiceSearch.tsx'

beforeEach(() => {
  vi.useFakeTimers()
})

afterEach(() => {
  vi.useRealTimers()
  vi.unstubAllGlobals()
  vi.restoreAllMocks()
})

describe('HoldTimer', () => {
  it('отсчитывает время и останавливает интервал, когда бронь истекла', () => {
    render(<HoldTimer initialSeconds={3} />)
    expect(screen.getByRole('status').textContent).toBe('Время держится ещё 0:03')

    act(() => vi.advanceTimersByTime(1000))
    expect(screen.getByRole('status').textContent).toBe('Время держится ещё 0:02')

    act(() => vi.advanceTimersByTime(2000))
    expect(screen.getByRole('status').textContent).toBe('Бронь истекла')
    expect(vi.getTimerCount()).toBe(0) // очистка сработала, интервалов не осталось
  })

  it('очищает интервал при размонтировании', () => {
    const { unmount } = render(<HoldTimer initialSeconds={60} />)
    expect(vi.getTimerCount()).toBe(1)
    unmount()
    expect(vi.getTimerCount()).toBe(0)
  })

  it('в StrictMode при разработке: запуск → очистка → запуск', () => {
    const setSpy = vi.spyOn(globalThis, 'setInterval')
    const clearSpy = vi.spyOn(globalThis, 'clearInterval')
    render(
      <StrictMode>
        <HoldTimer initialSeconds={60} />
      </StrictMode>,
    )
    expect(setSpy).toHaveBeenCalledTimes(2)
    expect(clearSpy).toHaveBeenCalledTimes(1)
    expect(vi.getTimerCount()).toBe(1) // в итоге работает ровно один интервал
  })
})

/* поддельный fetch: отвечает с задержкой и честно реагирует на AbortSignal */
function stubFetch(delays: Record<string, number>, signals: AbortSignal[]) {
  vi.stubGlobal(
    'fetch',
    vi.fn((url: string, init: RequestInit) => {
      const q = new URL(url, 'http://localhost').searchParams.get('q') ?? ''
      signals.push(init.signal!)
      return new Promise<Response>((resolve, reject) => {
        const timer = setTimeout(() => {
          const body = JSON.stringify([{ id: q, title: `Результат для «${q}»` }])
          resolve(new Response(body, { status: 200 }))
        }, delays[q] ?? 0)
        init.signal!.addEventListener('abort', () => {
          clearTimeout(timer)
          reject(new DOMException('Aborted', 'AbortError'))
        })
      })
    }),
  )
}

describe('ServiceSearch', () => {
  it('показывает ответ на последний запрос, а устаревший отменяет', async () => {
    const signals: AbortSignal[] = []
    stubFetch({ ма: 500, маникюр: 100 }, signals) // первый запрос медленнее второго
    render(<ServiceSearch />)
    const input = screen.getByLabelText('Поиск услуги')

    fireEvent.change(input, { target: { value: 'ма' } })
    fireEvent.change(input, { target: { value: 'маникюр' } })
    expect(screen.getByText('Ищем…')).toBeTruthy()

    await act(() => vi.advanceTimersByTimeAsync(600))

    expect(signals[0].aborted).toBe(true)
    expect(signals[1].aborted).toBe(false)
    expect(screen.getAllByRole('listitem').map((li) => li.textContent)).toEqual([
      'Результат для «маникюр»',
    ])
    expect(screen.queryByText('Ищем…')).toBeNull()
  })

  it('показывает ошибку, если сервер ответил не 2xx', async () => {
    vi.stubGlobal('fetch', vi.fn(async () => new Response('[]', { status: 500 })))
    render(<ServiceSearch />)
    fireEvent.change(screen.getByLabelText('Поиск услуги'), { target: { value: 'стрижка' } })
    await act(() => vi.advanceTimersByTimeAsync(0))
    expect(screen.getByRole('alert').textContent).toBe('Не удалось загрузить услуги')
  })
})
terminal
$ npm test

> booking-search@0.0.0 test
> vitest run

 RUN  v5.0.3 …/booking-search

 Test Files  3 passed (3)
      Tests  10 passed (10)
Три файла: основные тесты, демонстрация гонки в наивной версии и тесты исправленных примеров.

Эффекты и состояние — основа почти любого интерактивного компонента. Дальше по траектории React — формы и валидация, где управляемые поля из прошлого урока встречаются с реальными требованиями, а затем управление состоянием в больших приложениях и тестирование фронтенда.

Как проверено7 · пройденосверено 11 октября 2026 г.
  • запущено, работает

    src/components/HoldTimer.tsx (интервал + очистка, зависимость expired)

    vitest 5.0.3 с поддельными таймерами: отсчёт 0:03 → 0:02 → «Бронь истекла», getTimerCount() = 0; размонтирование очищает интервал; в StrictMode setInterval ×2, clearInterval ×1, активен 1 интервал

  • запущено, работает

    src/api/services.ts, src/components/ServiceSearch.tsx (AbortController, результат с запросом)

    фейковый fetch с задержками 500/100 мс и поддержкой AbortSignal: первый запрос отменён, на экране только результат последнего; ответ 500 → role=alert; без abort() в очистке тест падает (мутационная проверка)

  • запущено, работает

    src/bad/NaiveSearch.tsx (гонка)

    тест подтверждает, что без очистки на экране остаётся ответ на «ма» при запросе «маникюр»

  • запущено, работает

    src/bad/Unneeded.tsx и src/good/Fixed.tsx

    ESLint 10.12.0 + eslint-plugin-react-hooks 7.1.1 (flat recommended) + typescript-eslint 8.71.1: на Unneeded 2 ошибки set-state-in-effect и 1 предупреждение exhaustive-deps (вывод скопирован с сокращением), на Fixed/components/api — 0 замечаний; тесты Fixed: фильтр при рендере, один fetch на клик под StrictMode, сброс через key, document.title по name

  • запущено, работает

    Сборка проекта, eslint.config.js, src/main.tsx (шаблон)

    npm run build (tsc -b на TypeScript 6.0.3 + vite build 8.3.4) — успешно; tsc -b на TypeScript 7.0.2 — 0 ошибок; npm test — 3 файла, 10/10

  • запущено, работает

    Решения упражнений (продление, useDebouncedValue, ReviewDraft)

    копия проекта ex/: tsc -b, vite build, eslint — 0 замечаний, vitest 8/8; фрагменты сверены программно

  • запущено, работает

    EN-версия кода

    копии en/ и en-ex/: tsc -b, vite build, vitest 10/10 и 8/8, вывод ESLint совпадает по строкам

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

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

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

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