← React/Error Boundary: обработка ошибок в UI#276 из 383← ПредыдущийСледующий →+25 XP
Полезно по теме:Гайд: React или VueПрактика: React setТермин: React HooksМаршрут: старт с нуля

Error Boundary: обработка ошибок в UI

Проблема: падение всего приложения

Представьте: пользователь открывает ваш интернет-магазин. В одном из компонентов списка товаров приходит null вместо объекта — и React, не зная как отрисовать null.price, бросает ошибку. Без Error Boundary весь экран становится белым. Пользователь видит пустую страницу и уходит.

Error Boundary — это React-компонент, который перехватывает ошибки рендеринга в дочерних компонентах и вместо белого экрана показывает запасной UI (fallback).

Как работает Error Boundary

Error Boundary — это классовый компонент с двумя специальными методами жизненного цикла:

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props)
    this.state = { hasError: false, error: null }
  }

  // Вызывается при ошибке рендеринга — обновляет state
  static getDerivedStateFromError(error) {
    return { hasError: true, error }
  }

  // Вызывается после getDerivedStateFromError — для логирования
  componentDidCatch(error, errorInfo) {
    console.error('Ошибка компонента:', error)
    console.error('Стек компонентов:', errorInfo.componentStack)
    // Отправляем в Sentry, Datadog, etc.
    logErrorToService(error, errorInfo)
  }

  render() {
    if (this.state.hasError) {
      return <h2>Что-то пошло не так. Попробуйте обновить страницу.</h2>
    }
    return this.props.children
  }
}

// Использование:
<ErrorBoundary>
  <UserProfile userId={id} />  {/* если здесь ошибка — показывается fallback */}
</ErrorBoundary>

getDerivedStateFromError vs componentDidCatch

| Метод | Когда вызывается | Для чего |

|---|---|---|

| getDerivedStateFromError | Во время рендеринга (фаза render) | Обновить state → показать fallback UI |

| componentDidCatch | После рендеринга (фаза commit) | Логировать ошибку, side effects |

Важно: getDerivedStateFromError — статический метод (static), у него нет доступа к this. Он должен быть чистой функцией.

Что Error Boundary НЕ перехватывает

Error Boundary перехватывает только ошибки рендеринга (метод render и lifecycle методы). Следующие ошибки он не ловит:

  • Асинхронные ошибки: setTimeout, fetch, промисы — используйте try/catch
  • Обработчики событий: onClick — используйте try/catch внутри
  • Серверный рендеринг (SSR)
  • Ошибки в самом Error Boundary
  • // ОШИБКА: это НЕ поймает Error Boundary
    function BrokenButton() {
      const handleClick = () => {
        throw new Error('Ошибка в обработчике')  // не поймается!
      }
      return <button onClick={handleClick}>Нажми</button>
    }
    
    // ПРАВИЛЬНО: try/catch в обработчике
    function FixedButton() {
      const handleClick = () => {
        try {
          riskyOperation()
        } catch (error) {
          setError(error.message)
        }
      }
      return <button onClick={handleClick}>Нажми</button>
    }

    Библиотека react-error-boundary

    Писать классовый компонент каждый раз — неудобно. Библиотека react-error-boundary предоставляет готовое решение:

    import { ErrorBoundary } from 'react-error-boundary'
    
    function ErrorFallback({ error, resetErrorBoundary }) {
      return (
        <div role="alert">
          <p>Произошла ошибка:</p>
          <pre>{error.message}</pre>
          <button onClick={resetErrorBoundary}>Попробовать снова</button>
        </div>
      )
    }
    
    // Использование с кнопкой сброса:
    <ErrorBoundary
      FallbackComponent={ErrorFallback}
      onError={(error, info) => logError(error, info)}
      onReset={() => {
        // сбросить состояние приложения перед повтором
      }}
    >
      <UserDashboard />
    </ErrorBoundary>

    Механизм сброса (Reset)

    Одна из лучших возможностей react-error-boundary — кнопка "Попробовать снова". При нажатии компонент размонтируется и монтируется заново:

    import { useErrorBoundary } from 'react-error-boundary'
    
    // Программный сброс из дочернего компонента:
    function UserProfile() {
      const { showBoundary } = useErrorBoundary()
    
      async function fetchUser() {
        try {
          const data = await api.getUser()
          setUser(data)
        } catch (error) {
          showBoundary(error)  // пробрасываем ошибку в boundary
        }
      }
    }

    Стратегия размещения Error Boundary

    Не нужно оборачивать каждый компонент. Подумайте, какие части UI независимы:

    // Хорошо: изолируем виджеты
    <ErrorBoundary fallback={<p>Ошибка виджета</p>}>
      <WeatherWidget />
    </ErrorBoundary>
    
    <ErrorBoundary fallback={<p>Ошибка чата</p>}>
      <ChatWidget />
    </ErrorBoundary>
    
    // Если упадёт WeatherWidget — ChatWidget продолжит работать

    Общее правило: один Error Boundary на каждую независимую часть интерфейса.

    Примеры

    Реализация паттерна Error Boundary через JavaScript: перехват ошибок, логирование, сброс состояния и изолированные зоны ошибок

    // Демонстрируем концепцию Error Boundary на чистом JavaScript.
    // В React это классовый компонент, здесь — функция-обёртка с тем же поведением.
    
    // --- Симуляция компонентов ---
    
    function simulateRender(componentName, props) {
      // Некоторые компоненты "падают" при определённых данных
      if (componentName === 'UserCard' && props.user === null) {
        throw new Error('Cannot read property "name" of null')
      }
      if (componentName === 'PriceDisplay' && props.price < 0) {
        throw new Error('Недопустимая цена: ' + props.price)
      }
      return '[rendered: ' + componentName + ']'
    }
    
    // --- Error Boundary: паттерн обёртки ---
    
    function createErrorBoundary(options = {}) {
      const { onError, fallback = 'Что-то пошло не так', resetOnRetry = true } = options
    
      let state = {
        hasError: false,
        error: null,
        retryCount: 0,
      }
    
      function getDerivedStateFromError(error) {
        // Аналог статического метода в React
        return { hasError: true, error }
      }
    
      function componentDidCatch(error, context) {
        // Аналог метода жизненного цикла
        console.error('[ErrorBoundary] Поймана ошибка:', error.message)
        console.error('[ErrorBoundary] Контекст:', context)
        if (onError) onError(error, context)
      }
    
      function render(renderFn, context) {
        if (state.hasError) {
          console.log('[ErrorBoundary] Показываем fallback: "' + fallback + '"')
          console.log('[ErrorBoundary] Ошибка была:', state.error.message)
          return { output: fallback, error: state.error }
        }
    
        try {
          const output = renderFn()
          return { output, error: null }
        } catch (error) {
          const newState = getDerivedStateFromError(error)
          state = { ...state, ...newState }
          componentDidCatch(error, context || 'неизвестный компонент')
          // После обновления state — рендерим снова (теперь покажет fallback)
          return render(renderFn, context)
        }
      }
    
      function reset() {
        state = { hasError: false, error: null, retryCount: state.retryCount + 1 }
        console.log('[ErrorBoundary] Сброшен. Попытка #' + state.retryCount)
      }
    
      return { render, reset, getState: () => ({ ...state }) }
    }
    
    // --- Тест 1: Компонент падает ---
    
    console.log('=== Тест 1: Компонент падает ===')
    
    const boundary1 = createErrorBoundary({
      fallback: 'Ошибка загрузки профиля',
      onError: (err) => console.log('[Логирование] Отправка в Sentry:', err.message),
    })
    
    const result1 = boundary1.render(
      () => simulateRender('UserCard', { user: null }),
      'UserCard'
    )
    console.log('Вывод:', result1.output)  // fallback
    console.log('Состояние:', boundary1.getState())
    
    // --- Тест 2: Компонент работает нормально ---
    
    console.log('
    === Тест 2: Нормальная работа ===')
    
    const boundary2 = createErrorBoundary({ fallback: 'Ошибка цены' })
    
    const result2 = boundary2.render(
      () => simulateRender('PriceDisplay', { price: 1999 }),
      'PriceDisplay'
    )
    console.log('Вывод:', result2.output)  // нормальный рендер
    console.log('Есть ошибка:', boundary2.getState().hasError)  // false
    
    // --- Тест 3: Сброс и повтор ---
    
    console.log('
    === Тест 3: Сброс после ошибки ===')
    
    const boundary3 = createErrorBoundary({ fallback: 'Временная ошибка' })
    
    let shouldFail = true
    
    // Первый рендер — упадёт
    boundary3.render(() => {
      if (shouldFail) throw new Error('Временный сбой API')
      return 'Данные загружены'
    })
    
    console.log('После ошибки — hasError:', boundary3.getState().hasError)
    
    // "Исправляем" проблему и нажимаем Retry
    shouldFail = false
    boundary3.reset()
    
    const retryResult = boundary3.render(() => {
      if (shouldFail) throw new Error('Временный сбой API')
      return 'Данные загружены'
    })
    
    console.log('После сброса — вывод:', retryResult.output)  // 'Данные загружены'
    console.log('Попыток:', boundary3.getState().retryCount)  // 1
    
    // --- Тест 4: Изолированные зоны (несколько boundary) ---
    
    console.log('
    === Тест 4: Изолированные зоны ===')
    
    const weatherBoundary = createErrorBoundary({ fallback: '[Ошибка погодного виджета]' })
    const chatBoundary = createErrorBoundary({ fallback: '[Ошибка чата]' })
    
    const weather = weatherBoundary.render(() => {
      throw new Error('API погоды недоступен')
    })
    const chat = chatBoundary.render(() => 'Чат работает нормально')
    
    console.log('Погода:', weather.output)  // fallback
    console.log('Чат:', chat.output)        // нормально
    console.log('Чат упал?', chatBoundary.getState().hasError)  // false — изолировано!

    Error Boundary: обработка ошибок в UI

    Проблема: падение всего приложения

    Представьте: пользователь открывает ваш интернет-магазин. В одном из компонентов списка товаров приходит null вместо объекта — и React, не зная как отрисовать null.price, бросает ошибку. Без Error Boundary весь экран становится белым. Пользователь видит пустую страницу и уходит.

    Error Boundary — это React-компонент, который перехватывает ошибки рендеринга в дочерних компонентах и вместо белого экрана показывает запасной UI (fallback).

    Как работает Error Boundary

    Error Boundary — это классовый компонент с двумя специальными методами жизненного цикла:

    class ErrorBoundary extends React.Component {
      constructor(props) {
        super(props)
        this.state = { hasError: false, error: null }
      }
    
      // Вызывается при ошибке рендеринга — обновляет state
      static getDerivedStateFromError(error) {
        return { hasError: true, error }
      }
    
      // Вызывается после getDerivedStateFromError — для логирования
      componentDidCatch(error, errorInfo) {
        console.error('Ошибка компонента:', error)
        console.error('Стек компонентов:', errorInfo.componentStack)
        // Отправляем в Sentry, Datadog, etc.
        logErrorToService(error, errorInfo)
      }
    
      render() {
        if (this.state.hasError) {
          return <h2>Что-то пошло не так. Попробуйте обновить страницу.</h2>
        }
        return this.props.children
      }
    }
    
    // Использование:
    <ErrorBoundary>
      <UserProfile userId={id} />  {/* если здесь ошибка — показывается fallback */}
    </ErrorBoundary>

    getDerivedStateFromError vs componentDidCatch

    | Метод | Когда вызывается | Для чего |

    |---|---|---|

    | getDerivedStateFromError | Во время рендеринга (фаза render) | Обновить state → показать fallback UI |

    | componentDidCatch | После рендеринга (фаза commit) | Логировать ошибку, side effects |

    Важно: getDerivedStateFromError — статический метод (static), у него нет доступа к this. Он должен быть чистой функцией.

    Что Error Boundary НЕ перехватывает

    Error Boundary перехватывает только ошибки рендеринга (метод render и lifecycle методы). Следующие ошибки он не ловит:

  • Асинхронные ошибки: setTimeout, fetch, промисы — используйте try/catch
  • Обработчики событий: onClick — используйте try/catch внутри
  • Серверный рендеринг (SSR)
  • Ошибки в самом Error Boundary
  • // ОШИБКА: это НЕ поймает Error Boundary
    function BrokenButton() {
      const handleClick = () => {
        throw new Error('Ошибка в обработчике')  // не поймается!
      }
      return <button onClick={handleClick}>Нажми</button>
    }
    
    // ПРАВИЛЬНО: try/catch в обработчике
    function FixedButton() {
      const handleClick = () => {
        try {
          riskyOperation()
        } catch (error) {
          setError(error.message)
        }
      }
      return <button onClick={handleClick}>Нажми</button>
    }

    Библиотека react-error-boundary

    Писать классовый компонент каждый раз — неудобно. Библиотека react-error-boundary предоставляет готовое решение:

    import { ErrorBoundary } from 'react-error-boundary'
    
    function ErrorFallback({ error, resetErrorBoundary }) {
      return (
        <div role="alert">
          <p>Произошла ошибка:</p>
          <pre>{error.message}</pre>
          <button onClick={resetErrorBoundary}>Попробовать снова</button>
        </div>
      )
    }
    
    // Использование с кнопкой сброса:
    <ErrorBoundary
      FallbackComponent={ErrorFallback}
      onError={(error, info) => logError(error, info)}
      onReset={() => {
        // сбросить состояние приложения перед повтором
      }}
    >
      <UserDashboard />
    </ErrorBoundary>

    Механизм сброса (Reset)

    Одна из лучших возможностей react-error-boundary — кнопка "Попробовать снова". При нажатии компонент размонтируется и монтируется заново:

    import { useErrorBoundary } from 'react-error-boundary'
    
    // Программный сброс из дочернего компонента:
    function UserProfile() {
      const { showBoundary } = useErrorBoundary()
    
      async function fetchUser() {
        try {
          const data = await api.getUser()
          setUser(data)
        } catch (error) {
          showBoundary(error)  // пробрасываем ошибку в boundary
        }
      }
    }

    Стратегия размещения Error Boundary

    Не нужно оборачивать каждый компонент. Подумайте, какие части UI независимы:

    // Хорошо: изолируем виджеты
    <ErrorBoundary fallback={<p>Ошибка виджета</p>}>
      <WeatherWidget />
    </ErrorBoundary>
    
    <ErrorBoundary fallback={<p>Ошибка чата</p>}>
      <ChatWidget />
    </ErrorBoundary>
    
    // Если упадёт WeatherWidget — ChatWidget продолжит работать

    Общее правило: один Error Boundary на каждую независимую часть интерфейса.

    Примеры

    Реализация паттерна Error Boundary через JavaScript: перехват ошибок, логирование, сброс состояния и изолированные зоны ошибок

    // Демонстрируем концепцию Error Boundary на чистом JavaScript.
    // В React это классовый компонент, здесь — функция-обёртка с тем же поведением.
    
    // --- Симуляция компонентов ---
    
    function simulateRender(componentName, props) {
      // Некоторые компоненты "падают" при определённых данных
      if (componentName === 'UserCard' && props.user === null) {
        throw new Error('Cannot read property "name" of null')
      }
      if (componentName === 'PriceDisplay' && props.price < 0) {
        throw new Error('Недопустимая цена: ' + props.price)
      }
      return '[rendered: ' + componentName + ']'
    }
    
    // --- Error Boundary: паттерн обёртки ---
    
    function createErrorBoundary(options = {}) {
      const { onError, fallback = 'Что-то пошло не так', resetOnRetry = true } = options
    
      let state = {
        hasError: false,
        error: null,
        retryCount: 0,
      }
    
      function getDerivedStateFromError(error) {
        // Аналог статического метода в React
        return { hasError: true, error }
      }
    
      function componentDidCatch(error, context) {
        // Аналог метода жизненного цикла
        console.error('[ErrorBoundary] Поймана ошибка:', error.message)
        console.error('[ErrorBoundary] Контекст:', context)
        if (onError) onError(error, context)
      }
    
      function render(renderFn, context) {
        if (state.hasError) {
          console.log('[ErrorBoundary] Показываем fallback: "' + fallback + '"')
          console.log('[ErrorBoundary] Ошибка была:', state.error.message)
          return { output: fallback, error: state.error }
        }
    
        try {
          const output = renderFn()
          return { output, error: null }
        } catch (error) {
          const newState = getDerivedStateFromError(error)
          state = { ...state, ...newState }
          componentDidCatch(error, context || 'неизвестный компонент')
          // После обновления state — рендерим снова (теперь покажет fallback)
          return render(renderFn, context)
        }
      }
    
      function reset() {
        state = { hasError: false, error: null, retryCount: state.retryCount + 1 }
        console.log('[ErrorBoundary] Сброшен. Попытка #' + state.retryCount)
      }
    
      return { render, reset, getState: () => ({ ...state }) }
    }
    
    // --- Тест 1: Компонент падает ---
    
    console.log('=== Тест 1: Компонент падает ===')
    
    const boundary1 = createErrorBoundary({
      fallback: 'Ошибка загрузки профиля',
      onError: (err) => console.log('[Логирование] Отправка в Sentry:', err.message),
    })
    
    const result1 = boundary1.render(
      () => simulateRender('UserCard', { user: null }),
      'UserCard'
    )
    console.log('Вывод:', result1.output)  // fallback
    console.log('Состояние:', boundary1.getState())
    
    // --- Тест 2: Компонент работает нормально ---
    
    console.log('
    === Тест 2: Нормальная работа ===')
    
    const boundary2 = createErrorBoundary({ fallback: 'Ошибка цены' })
    
    const result2 = boundary2.render(
      () => simulateRender('PriceDisplay', { price: 1999 }),
      'PriceDisplay'
    )
    console.log('Вывод:', result2.output)  // нормальный рендер
    console.log('Есть ошибка:', boundary2.getState().hasError)  // false
    
    // --- Тест 3: Сброс и повтор ---
    
    console.log('
    === Тест 3: Сброс после ошибки ===')
    
    const boundary3 = createErrorBoundary({ fallback: 'Временная ошибка' })
    
    let shouldFail = true
    
    // Первый рендер — упадёт
    boundary3.render(() => {
      if (shouldFail) throw new Error('Временный сбой API')
      return 'Данные загружены'
    })
    
    console.log('После ошибки — hasError:', boundary3.getState().hasError)
    
    // "Исправляем" проблему и нажимаем Retry
    shouldFail = false
    boundary3.reset()
    
    const retryResult = boundary3.render(() => {
      if (shouldFail) throw new Error('Временный сбой API')
      return 'Данные загружены'
    })
    
    console.log('После сброса — вывод:', retryResult.output)  // 'Данные загружены'
    console.log('Попыток:', boundary3.getState().retryCount)  // 1
    
    // --- Тест 4: Изолированные зоны (несколько boundary) ---
    
    console.log('
    === Тест 4: Изолированные зоны ===')
    
    const weatherBoundary = createErrorBoundary({ fallback: '[Ошибка погодного виджета]' })
    const chatBoundary = createErrorBoundary({ fallback: '[Ошибка чата]' })
    
    const weather = weatherBoundary.render(() => {
      throw new Error('API погоды недоступен')
    })
    const chat = chatBoundary.render(() => 'Чат работает нормально')
    
    console.log('Погода:', weather.output)  // fallback
    console.log('Чат:', chat.output)        // нормально
    console.log('Чат упал?', chatBoundary.getState().hasError)  // false — изолировано!

    Задание

    Создай Error Boundary компонент на классах, который перехватывает ошибки в дочерних компонентах. Добавь кнопку "Повторить" для сброса ошибки. Компонент BuggyCounter должен падать при достижении счётчика 5.

    Подсказка

    В execute: оберни вызов component(props) в try/catch. В catch увеличь errorCount++, сохрани error в lastError, вызови onError?.(error), верни { success: false, error, fallback }. В try верни { success: true, result }. getErrorCount просто возвращает errorCount.

    Загружаем среду выполнения...
    Загружаем AI-помощника...