Привет! Я Дима, фронтенд-разработчик в Surf. Сегодня рассмотрим самую популярную библиотеку для фронтенда — React. Что было в React18? Давайте узнаем!

React, разработанный Meta* (ранее Facebook*), остаётся одной из ведущих библиотек для создания пользовательских интерфейсов.


В статье будут ссылки на документацию React, которые ведут на официальный сайт продукта, созданный компанией Meta*. Компания Meta* признана экстремистской организацией, ее деятельность на территории России запрещена. 


С каждым новым релизом разработчики получают мощные инструменты и улучшения, которые упрощают разработку и повышают производительность приложений.

На горизонте маячит React 19, который уже тестируется и обещает ещё больше интересных фич. Самое время вспомнить, какие инновации привнёс React 18 и как они повлияли на работу с интерфейсами. 

1. Concurrent React: Новая Эра Рендеринга

Concurrent React, введённый в React 18, — фундаментальное обновление модели рендеринга. Оно улучшает производительность и плавность пользовательского опыта. 

Привычный нам синхронный рендеринг тут выполняется непрерывно и блокирует интерфейс до завершения. При параллельном — это не всегда так. React может остановить рендеринг в середине и продолжить позже. Или же полностью его прекратить. 

Нам дают гарантию, что интерфейс будет выглядеть целостным, даже если рендеринг будет прерван. React откладывает изменения в DOM до тех пор, пока всё дерево не будет готово — это позволяет избежать нежелательного отображения.

Ключевое преимущество параллельного рендеринга — его способность параллельно (вот это поворот!) готовить несколько версий интерфейса. Так что даже если выполняется большая задача рендеринга, он может быстро реагировать на пользовательские события: клики, скроллы и т.п.

Одной из функций, которая использует возможности Concurrent React, стал компонент <Offscreen>. Он позволит готовить интерфейсы в фоновом режиме и сохранять состояние. Что особенно полезно при переключении между экранами.

В React 18 мы увидели начало внедрения параллельного рендеринга. Он уже поддерживает Suspense, переходы и потоковый серверный рендеринг — что отлично подходит для разработки отзывчивых и высокопроизводительных приложений.

 2. Automatic Batching (Автоматическое пакетирование)

Automatic Batching — одно из ключевых и приятных нововведений React 18, которое направлено на повышение производительности и удобства разработки. 

Пакетирование (batching) — процесс, при котором React объединяет несколько обновлений состояния в один рендеринг, чтобы минимизировать количество повторных рендеров и сделать приложение более отзывчивым.

До внедрения этого процесса пакетирование происходило только в рамках обработчиков событий React. Если мы обновляли состояние внутри других асинхронных операций (промисов, setTimeout, или нативных обработчиков событий), каждое обновление вызывало отдельный рендеринг. 

Это вело к снижению производительности — каждое изменение состояния заставляло React выполнять рендеринг. И чтобы этого избегать, приходилось оборачивать методы обновления в batch.  А это часто забывается.

Теперь обновления состояния группируются автоматически. Даже если они происходят в асинхронных операциях или других контекстах вне стандартных обработчиков событий React. 

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

setTimeout(() => {
   setCount((c) => c + 1);
   setFlag((f) => !f);
}, 1000);

Раньше React выполнил бы рендеринг дважды — по одному для каждого обновления состояния. Теперь ждём один рендер в конце. Вот оно, автоматическое пакетирование.

Ещё автоматическое пакетирование упрощает управление состоянием, особенно в сложных асинхронных сценариях. Теперь нам не нужно вручную контролировать, где и как происходит пакетирование, что облегчает написание чистого и производительного кода.

3. Transitions (Переходы)

Переходы — новая концепция в React 18. Она позволяет разграничить срочные и несрочные обновления пользовательского интерфейса.

Срочные обновления — действия, на которые пользователь ожидает немедленной реакции: ввод текста, нажатие кнопки или щелчок мыши. Такие обновления должны быть выполнены без задержек.

Переходные (несрочные) обновления — изменения, которые пользователи не ожидают увидеть сразу: изменение фильтров или обновление результатов поиска после выбора критерия. Эти обновления могут происходить с задержкой и часто требуют мгновенной визуальной обратной связи.

Так вот, React 18 вводит два способа работы с переходами:

  1. startTransition

Эта функция используется для обозначения несрочных обновлений внутри обработчиков событий. 

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

import { startTransition } from 'react';

// Срочно: Отобразить введённое значение сразу
setInputValue(input);

// Переход: Обновить результаты поиска
startTransition(() => {
   setSearchQuery(input);
});

Здесь startTransition сообщает React, что обновление результатов поиска переходное, и его можно приостановить или отменить, если пользователь выполнит другое срочное действие.

  1. useTransition

Это хук, который позволяет управлять переходами в функциональных компонентах. Он возвращает массив с двумя значениями: 

  1. с булевым флагом isPending, который указывает, находится ли переход в процессе выполнения;

  2. с функцией для запуска перехода.

const [isPending, startTransition] = useTransition();

function handleInputChange(input) {
   setInputValue(input);

   startTransition(() => {
       setSearchQuery(input);
   });
}

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

Взаимодействие с параллельным рендерингом

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

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

Переходы и параллельный рендеринг вместе повышают гибкость работы React. 

4. Новые функциональные возможности Suspense

React 18 добавил поддержку Suspense на сервере и улучшил его возможности при помощи параллельного рендеринга. 

Лучше всего Suspense работает с Transitions API.

Он также позволяет декларативно управлять состоянием загрузки в интерфейсе, особенно когда часть дерева компонентов ещё не готова к отображению.

Принципы работы Suspense

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

<Suspense fallback={<Spinner />}>
   <Comments />
</Suspense>;

Здесь компонент <Comments /> не рендерится — данные для его работы не загружены. Когда они загружаются, пользователь видит компонент <Spinner />.

Поддержка на стороне сервера

В серверном рендеринге мы не могли пользоваться Suspense до этого момента. Зато теперь его можно использовать для работы с медленно загружающимися данными при рендеринге на сервере. 

Это позволяет отложить рендеринг на стороне клиента до тех пор, пока не будет доступен достаточный объём данных. Это поможет предотвратить мигание загрузочных индикаторов или частичное отображение контента.

Интеграция с параллельным рендерингом и переходами

Когда обновление запускается через переход (например, с помощью startTransition), и компонент вынужден ожидать загрузки данных, React подождёт, прежде чем обновить интерфейс. 

Это значит, что пользователь сможет продолжать видеть текущие данные, пока новые готовятся «за кулисами», без появления лишних индикаторов загрузки.

import { Suspense, startTransition } from 'react';

function App() {
   const [page, setPage] = useState('home');

   const handleClick = () => {
       startTransition(() => {
           setPage('comments');
       });
   };

   return (
       <div>
           <button onClick={handleClick}>Show Comments</button>
           <Suspense fallback={<Spinner />}>
              {page === 'comments' && <Comments />}
           </Suspense>
       </div>
   );
}

5. Новые API клиентского и серверного рендеринга

React DOM Client

Новые API для клиентского рендеринга доступны через react-dom/client и включают:

  • createRoot — новый метод для создания корня приложения, который заменяет ReactDOM.render. Он нужен для использования параллельного рендеринга и автоматического пакетирования обновлений;

  • hydrateRoot — метод для гидратации серверного рендеринга на клиенте и заменяет ReactDOM.hydrate. Он тоже поддерживает новые возможности React 18: параллельный рендеринг и Suspense.

Оба метода принимают опцию onRecoverableError, которая позволяет логировать ошибки во время рендеринга или гидратации. По умолчанию, React использует reportError или console.error в старых браузерах.

import { createRoot, hydrateRoot } from 'react-dom/client';

// Используем createRoot для новой инициализации приложения
const root = createRoot(document.getElementById('app'));
root.render(<App />);

// Используем hydrateRoot для гидратации серверного рендеринга на клиенте
hydrateRoot(document.getElementById('app'), <App />);

React DOM Server

Новые API для серверного рендеринга, доступные через react-dom/server, обеспечивают полную поддержку потоковой передачи и Suspense:

  • renderToPipeableStream. Используется для потоковой передачи в окружениях Node.js. Этот метод позволяет обрабатывать большие объёмы данных и взаимодействовать с клиентом, пока сервер продолжает рендеринг;

  • renderToReadableStream. Предназначен для современных окружений edge runtime (Deno и Cloudflare Workers) и поддерживает потоковую передачу данных с учётом особенностей этих платформ.

Старый метод renderToString всё ещё доступен. Но не стоит использовать его для новых проектов — он не поддерживает возможности React 18 (к примеру, Suspense).

import { renderToPipeableStream } from 'react-dom/server';
import express from 'express';
import App from './App';

// Инициализация Express сервера
const server = express();

server.get('/', (req, res) => {
   // Используем renderToPipeableStream для потокового рендеринга с поддержкой Suspense
   const stream = renderToPipeableStream(<App />, {
       onShellReady() {
           // Устанавливаем заголовок ответа
           res.setHeader('Content-Type', 'text/html');

           // Начинаем потоковую передачу ответа
           stream.pipe(res);
       },
       onShellError(error) {
           // Обрабатываем ошибки
           console.error(error);
           res.status(500).send('Internal Server Error');
       },
       onError(error) {
           // Логируем ошибки, но продолжаем рендеринг
           console.error(error);
       },
   });
});

server.listen(3000, () => {
   console.log('Server is listening on port 3000');
});
  • renderToPipeableStream. Этот метод используется для потоковой передачи HTML-ответа в Node.js-сервере. Он интегрирован с Suspense, что позволяет серверу отправить часть содержимого до полной загрузки всего приложения;

  • Suspense в SSR. Если компонент внутри App использует Suspense для загрузки данных, сервер может начать отправлять оболочку (shell) HTML-кода клиенту сразу после её готовности, не дожидаясь завершения загрузки всех данных;

  • onShellReady. Этот коллбэк вызывается, когда React готов начать потоковую передачу HTML-кода. Он полезен для отправки начальных частей страницы до завершения рендеринга всего содержимого;

  • onShellErrorиonError. Эти коллбэки позволяют обрабатывать и логировать ошибки, которые могут возникнуть во время рендеринга, и обеспечивать корректное завершение ответа.

6. Новые хуки

useId

useId — хук для генерации уникальных идентификаторов, при этом одинаковых и на клиенте, и на сервере, что предотвращает несоответствия при гидратации. 

Этот хук полезен в компонентах, которые работают с accessibility API, требующими уникальных идентификаторов, таких как aria-labelledby или aria-describedby

Отметим, что useId не предназначен для генерации ключей списка, которые должны основываться на ваших данных.

const id = useId();

return (
   <div>
       <label htmlFor={id}>Name</label>
       <input id={id} type="text" />
   </div>
);

useTransition

useTransition и связанный с ним startTransition позволяют классифицировать обновления состояния как несрочные и оставлять остальные срочными по умолчанию. 

Это позволяет React приостанавливать несрочные обновления, если происходят срочные: ввод текста, к примеру.

const [isPending, startTransition] = useTransition();

function handleChange(event) {
   startTransition(() => {
       setSearchQuery(event.target.value);
   });
}

useDeferredValue

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

const deferredValue = useDeferredValue(value);

return <List items={deferredValue} />;

useSyncExternalStore

useSyncExternalStore упрощает работу с внешними хранилищами состояния (stores) и обеспечивает их синхронное обновление. Это важно для библиотек, которые работают с внешними данными и состояниями — хук поддерживает конкурентное чтение и устраняет необходимость в useEffect для подписок.

const state = useSyncExternalStore(
   subscribeToStore,
   getSnapshotFromStore
 );

Важно! useSyncExternalStore лучше использовать в библиотеках, а не в пользовательском коде приложения.

useInsertionEffect

useInsertionEffect предназначен для библиотек CSS-in-JS и решает проблемы производительности при инъекции стилей во время рендеринга. Этот хук срабатывает после изменения DOM, но до того, как layout эффекты начинают считывать новое расположение элементов. Это особенно важно в условиях параллельного рендеринга, где React может позволить браузеру пересчитывать размещение элементов.

useInsertionEffect(() => {
   // Вставляем стили в DOM
   const style = document.createElement('style');
   document.head.appendChild(style);
   return () => {
       document.head.removeChild(style);
   };
}, []);

Важно! useInsertionEffect также предназначен для использования в библиотеках, а не в коде приложения.

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

Что в итоге

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

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

Как разработчики мы рады, что React продолжает развиваться, и очень ждём, что принесёт нам React 19. Это будет шаг в сторону ещё более удобного и производительного инструмента для создания интерфейсов. И нет, это не реклама. Мы правда очень любим React!

Больше полезного про веб-разработку — в Telegram-канале Surf Web Team. 

Кейсы, лучшие практики, новости и вакансии в команду Web Surf в одном месте. Присоединяйтесь!


*Компания Meta Platforms Inc. (Facebook и Instagram) признана экстремистской организацией, ее деятельность на территории России запрещена.

Комментарии (2)


  1. isumix
    03.10.2024 13:39

    Я вот как раз и затеял Fusor чтобы можно было явно контролировать обновления хоть пакетные, хоть одиночные, также можно самому рендерить и обновлять сложные участки дом асинхронно или синхронно, также диффить сложные структуры там где это важно.
    Реакт это черная коробочка которая оптимизирована на средний/общий по больнице результат. Когда нужна максимальная оптимизация, то он уже не подходит.


  1. Vitaly_js
    03.10.2024 13:39
    +1

    Важно! useSyncExternalStore лучше использовать в библиотеках, а не в пользовательском коде приложения.

    Это почему?