Dev Highlights

Доки по разработке

This project is maintained by teniryte

Расширенные API React

Этот файл описывает более продвинутые возможности React: useReducer, useMemo, useCallback, хук use, контекст, императивные ref, flushSync, useSyncExternalStore, а также основы клиентской гидратации и простого Suspense.

Батчинг обновлений состояния

Начиная с React 18, несколько вызовов setState в одном “тики” событий объединяются (батчатся) в один рендер. Это справедливо не только для обработчиков событий, но и для промисов, setTimeout и других асинхронных источников.

export function BatchingExample() {
  const [count, setCount] = useState(0);
  const [text, setText] = useState('hello');

  function handleClick() {
    // Эти два вызова обычно приведут к одному ререндеру
    setCount(c => c + 1);
    setText(t => t.toUpperCase());
  }

  return (
    <div>
      <button onClick={handleClick}>
        Count: {count}
      </button>
      <div>{text}</div>
    </div>
  );
}

Важно понимать: setState асинхронен в том смысле, что переменная state не меняется мгновенно — новое значение появится только при следующем рендере.

useReducer: сложное и связанное состояние

useReducer — альтернатива useState для случаев, когда:

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

type CounterState = { count: number };
type CounterAction =
  | { type: 'increment' }
  | { type: 'decrement' }
  | { type: 'reset'; payload?: number };

function counterReducer(state: CounterState, action: CounterAction): CounterState {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    case 'reset':
      return { count: action.payload ?? 0 };
    default:
      return state;
  }
}

// Ленивая инициализация
function createInitialState(initialCount: number): CounterState {
  console.log('Создаём начальное состояние');
  return { count: initialCount };
}

export function CounterWithReducer() {
  const [state, dispatch] = useReducer(counterReducer, 0, createInitialState);

  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
      <button onClick={() => dispatch({ type: 'reset', payload: 10 })}>Reset to 10</button>
    </div>
  );
}

Рекомендация: не помещайте сайд‑эффекты внутрь редьюсера, вместо этого реагируйте на изменения состояния с помощью useEffect.

useMemo и useCallback

useMemo: кеширование дорогих вычислений

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

export function FilterList({ items }: { items: string[] }) {
  const [query, setQuery] = useState('');

  const filtered = useMemo(() => {
    // имитация тяжёлой операции
    const start = performance.now();
    while (performance.now() - start < 5) {}

    return items.filter(item =>
      item.toLowerCase().includes(query.toLowerCase()),
    );
  }, [items, query]);

  return (
    <div>
      <input
        value={query}
        onChange={e => setQuery(e.target.value)}
        placeholder="Поиск..."
      />
      <ul>
        {filtered.map(item => (
          <li key={item}>{item}</li>
        ))}
      </ul>
    </div>
  );
}

useCallback: стабильные обработчики

useCallback мемоизирует функцию, чтобы дочерние компоненты не получали новый колбэк при каждом рендере (что важно при использовании React.memo).

const IncrementButton = React.memo(function IncrementButton({
  onClick,
}: {
  onClick: () => void;
}) {
  console.log('Render button');
  return <button onClick={onClick}>Increment</button>;
});

export function CallbackExample() {
  const [count, setCount] = useState(0);

  const increment = useCallback(() => {
    setCount(c => c + 1);
  }, []);

  return (
    <div>
      <p>Count: {count}</p>
      <IncrementButton onClick={increment} />
    </div>
  );
}

Хук use для промисов и контекста

Хук use позволяет “распаковать” промис или контекст прямо внутри компонента, без await и без отдельных useEffect. Обычно используется в сочетании с Suspense.

Идея:

async function fetchUser(id: number) {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error('Failed to load');
  return res.json() as Promise<{ id: number; name: string }>;
}

const userPromise = fetchUser(1);

function UserInfo() {
  const user = use(userPromise);
  return <div>User: {user.name}</div>;
}

export function UserPage() {
  return (
    <Suspense fallback={<div>Загрузка...</div>}>
      <UserInfo />
    </Suspense>
  );
}

Контекст и собственные хуки

Контекст позволяет “пробрасывать” данные через несколько уровней дерева без пропсов. Современный подход: создать контекст и обёртку‑хук, который проверяет, что компонент находится внутри провайдера.

type User = { name: string; age: number } | null;

type UserContextValue = {
  user: User;
  setUser: (user: User) => void;
};

const UserContext = createContext<UserContextValue | null>(null);

export function UserProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User>(null);

  return (
    <UserContext.Provider value={{ user, setUser }}>
      {children}
    </UserContext.Provider>
  );
}

export function useUser() {
  const ctx = useContext(UserContext);
  if (!ctx) {
    throw new Error('useUser должен вызываться внутри UserProvider');
  }
  return ctx;
}

export function UserPanel() {
  const { user, setUser } = useUser();

  return (
    <div>
      <h2>User</h2>
      {user ? (
        <>
          <div>Name: {user.name}</div>
          <div>Age: {user.age}</div>
        </>
      ) : (
        <div>No user</div>
      )}
      <button onClick={() => setUser({ name: 'Denis', age: 34 })}>
        Set user
      </button>
      <button onClick={() => setUser(null)}>Clear</button>
    </div>
  );
}

useImperativeHandle и императивные методы

Иногда нужно дать родителю императивный интерфейс к дочернему компоненту (например, метод focus, scrollToTop, open и т.п.). Для этого используют комбинацию forwardRef и useImperativeHandle.

type ColorHandle = {
  setColor: (color: string) => void;
};

const ColorBox = forwardRef<ColorHandle, { initial?: string }>(
  function ColorBox({ initial = 'white' }, ref) {
    const [color, setColor] = useState(initial);

    useImperativeHandle(ref, () => ({
      setColor(newColor: string) {
        setColor(newColor);
      },
    }));

    return (
      <div style={{ width: 100, height: 100, background: color }} />
    );
  },
);

export function ImperativeExample() {
  const ref = useRef<ColorHandle | null>(null);

  return (
    <div>
      <ColorBox ref={ref} initial="lightgray" />
      <button onClick={() => ref.current?.setColor('red')}>Red</button>
      <button onClick={() => ref.current?.setColor('green')}>Green</button>
    </div>
  );
}

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

flushSync для синхронных обновлений

Иногда необходимо, чтобы обновление состояния произошло сразу в текущем кадре, без ожидания батчинга. Для этого в react-dom есть утилита flushSync, которая временно отключает планировщик и заставляет React синхронно обновить DOM.

Типовые случаи:

import { flushSync } from 'react-dom';

export function FlushSyncExample() {
  const [showInput, setShowInput] = useState(false);
  const inputRef = useRef<HTMLInputElement | null>(null);

  const toggle = () => {
    flushSync(() => {
      setShowInput(prev => !prev);
    });

    // к этому моменту DOM уже обновлён
    inputRef.current?.focus();
  };

  return (
    <div>
      <button onClick={toggle}>Toggle input</button>
      {showInput && (
        <input ref={inputRef} placeholder="Фокус сразу здесь" />
      )}
    </div>
  );
}

Используйте flushSync очень точечно: он может сильно ударить по производительности, если в блоке много обновлений.

useSyncExternalStore: работа с внешними сторами

useSyncExternalStore — специальный хук для подписки на внешние источники данных (вне React): объекты, глобальные сторы, WebSocket, события окна и т.д. Он гарантирует корректную работу в concurrent‑режиме и отсутствие “разрывов” (tearing).

Нужно передать три функции:

function useOnlineStatus() {
  const subscribe = (callback: () => void) => {
    window.addEventListener('online', callback);
    window.addEventListener('offline', callback);
    return () => {
      window.removeEventListener('online', callback);
      window.removeEventListener('offline', callback);
    };
  };

  const getSnapshot = () => navigator.onLine;
  const getServerSnapshot = () => true; // допустимое значение по умолчанию

  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}

export function OnlineIndicator() {
  const isOnline = useOnlineStatus();

  return (
    <div>
      Статус: {isOnline ? 'online' : 'offline'}
    </div>
  );
}

Фабрика хуков на базе useSyncExternalStore

Частый паттерн — создать функцию, которая на основе какого‑то источника (например, matchMedia) возвращает хук для подписки на него.

function makeMediaQueryHook(query: string) {
  return function useMediaQuery() {
    return useSyncExternalStore(
      callback => {
        const media = window.matchMedia(query);
        media.addEventListener('change', callback);
        return () => media.removeEventListener('change', callback);
      },
      () => window.matchMedia(query).matches,
      () => false,
    );
  };
}

const useNarrowScreen = makeMediaQueryHook('(max-width: 600px)');

export function MediaExample() {
  const isNarrow = useNarrowScreen();

  return (
    <div>
      {isNarrow ? 'Мобильный режим' : 'Десктопный режим'}
    </div>
  );
}

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

Гидратация на клиенте

При серверном рендеринге React сначала генерирует HTML на сервере, а потом “привязывает” к нему обработчики событий на клиенте — этот процесс называется гидратацией.

Вручную это выглядит так:

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

function App() {
  return <button onClick={() => alert('Click')}>Click me</button>;
}

// На сервере
const html = renderToString(<App />);

// На клиенте
const container = document.getElementById('root')!;
container.innerHTML = html;

// Привязать обработчики к уже существующему HTML:
hydrateRoot(container, <App />);

В фреймворках (Next.js и др.) этот процесс скрыт внутри, но понимание принципа помогает диагностировать ошибки “Text content does not match server render”.

Простой Suspense и lazy

Suspense — это механизм отложенной отрисовки: пока часть дерева “занята” (например, загружает данные или код), React показывает fallback.

Комбинация React.lazy + Suspense — базовый паттерн ленивой загрузки компонентов.

const UsersList = React.lazy(() =>
  import('./UsersList').then(mod => ({ default: mod.UsersList })),
);

async function fetchUsers() {
  const res = await fetch('/api/users');
  if (!res.ok) throw new Error('Failed to load');
  return res.json() as Promise<Array<{ id: number; name: string }>>;
}

function UsersSection() {
  const users = use(fetchUsers());

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

export function UsersPage() {
  return (
    <Suspense fallback={<div>Загрузка пользователей...</div>}>
      <h1>Users</h1>
      <UsersList />
      {/* или напрямую <UsersSection /> */}
    </Suspense>
  );
}

Дальнейшее развитие темы Suspense — работа с кэшем промисов, nested Suspense, transitions и оптимистичными обновлениями.