Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
💻
Технологии
Опубликовано:
14.09.2026
Обновлено:
14.09.2026

Signal-based state в React: архитектура, интеграция Preact Signals и Jotai со строгой типизацией

Тимофей Ищенко

Коротко: Практическое руководство по Signal-based state в React: интеграция @preact/signals-react и Jotai, строгая типизация TypeScript и оптимизация ререндеров.

Классическая модель реактивности React построена на рендере сверху вниз: при изменении useState или useReducer компонент и все его дочерние узлы по умолчанию запускают повторное вычисление Virtual DOM. Оптимизации через useMemo, useCallback и React.memo превращаются в рутину, усложняют чтение кодовой базы и нередко приводят к скрытым просадкам производительности.

Fine-grained reactivity (гранулярная реактивность) решает эту проблему на фундаментальном уровне. Сигналы (Signals) и атомы (Jotai) позволяют обновлять только те конкретные узлы DOM или компоненты, которые напрямую читают изменившееся значение, полностью исключая каскадные ререндеры. Ниже разобран практический переход на сигналы и атомы в React-приложениях с акцентом на строгую типизацию в TypeScript.


Механика Signal-based подхода: чем сигналы отличаются от useState и селекторов

В традиционном подходе React состояние привязано к жизненному циклу компонента. Чтобы передать состояние вглубь дерева, разработчики используют пропсы, Context API или селекторы (например, в Redux Toolkit или Zustand). Контекст вызывает ререндер всех потребителей при любом изменении объекта, а селекторы требуют ручной мемоизации и проверок равенства (shallow equality).

Сигналы меняют парадигму: состояние существует как независимый реактивный контейнер вне дерева компонентов.

Принцип работы .value, computed() и effect()

Ядро сигналов базируется на трех примитивах:

  1. signal(initialValue) — реактивный контейнер. Чтение свойства .value внутри контекста отслеживания автоматически регистрирует подписчика. Запись в .value уведомляет только активных подписчиков.
  2. computed(fn) — мемоизированное производное значение. Функция выполняется лениво (только при запросе .value) и кеширует результат до тех пор, пока не изменится хотя бы один исходный сигнал внутри нее.
  3. effect(fn) — функция для синхронной реакции на изменения. Автоматически собирает зависимости при первом запуске и повторно вызывается при обновлении любого прочитанного сигнала.
import { signal, computed, effect } from '@preact/signals-core';

// Базовый сигнал со строгой типизацией
const count = signal<number>(0);

// Производное состояние: тип выводится автоматически как ReadonlySignal<number>
const double = computed(() => count.value * 2);

// Побочный эффект: подписывается на count.value
const dispose = effect(() => {
  console.log(`Count: ${count.value}, Double: ${double.value}`);
});

count.value = 5; // В консоли: "Count: 5, Double: 10"
dispose(); // Отписка от обновлений

Гранулярные обновления UI в обход Virtual DOM

Когда компонент читает signal.value, подписка оформляется на уровне конкретного вызова. В отличие от useState, где вызов сеттера планирует полный ререндер компонента в планировщике React (Fiber reconciler), сигналы позволяют точечно обновлять фрагменты дерева или отдельные текстовые узлы без повторного прогона всего тела родительского компонента через VDOM diffing.


Интеграция Preact Signals в React-приложение

Для использования сигналов внутри React-экосистемы существует отдельный набор библиотек от команды Preact.

Выбор правильного пакета: @preact/signals-react vs @preact/signals-core

  • @preact/signals-core — независимое от фреймворков ядро, написанное на TypeScript. Содержит только базовые примитивы signal, computed, effect, batch. Не содержит биндингов к React.
  • @preact/signals-react — официальный адаптер для React. Добавляет интеграцию с циклом рендеринга React-компонентов.
  • @preact/signals — предназначен только для Preact. Установка этого пакета в чистый React-проект приведет к ошибкам сборки и некорректной работе рантайма.

Установка для React-проекта:

npm install @preact/signals-react

Настройка окружения: Babel-трансформер vs хук useSignals

Чтобы React-компонент автоматически реагировал на обращение к .value, библиотека должна перехватить рендер. Есть два способа организации этой связи:

Вариант 1. Автоматическая трансформация кода (рекомендуется)

Подключается через Babel-плагин @preact/signals-react-transform. Плагин анализирует компоненты и автоматически оборачивает чтение сигналов в реактивный контекст.

В .babelrc или конфигурации сборщика:

{
  "plugins": [["module:@preact/signals-react-transform"]]
}

После этого любой функциональный компонент подписывается на сигналы автоматически:

import React from 'react';
import { signal } from '@preact/signals-react';

const counterSignal = signal<number>(0);

export const CounterView: React.FC = () => {
  return (
    <div>
      <p>Значение: {counterSignal.value}</p>
      <button onClick={() => counterSignal.value++}>Инкремент</button>
    </div>
  );
};

Вариант 2. Ручное отслеживание через хук useSignals

Если вы используете сборщики без возможности настройки Babel (например, изолированные сборки Vite/esbuild без кастомных трансформеров AST), применяется явный вызов хука:

import React from 'react';
import { useSignals } from '@preact/signals-react/runtime';
import { signal } from '@preact/signals-react';

const isOnlineSignal = signal<boolean>(false);

export const StatusBadge: React.FC = () => {
  useSignals(); // Явная активация отслеживания для данного компонента

  return <span>Статус: {isOnlineSignal.value ? 'В сети' : 'Не в сети'}</span>;
};

Строгая типизация сигналов: дженерики, readonly-сигналы и кастомные структуры

Библиотека написана на TypeScript и поддерживает автоматический вывод типов (type inference), однако для сложных структур данных и интерфейсов рекомендуется задавать типы явно:

import { signal, computed, ReadonlySignal, Signal } from '@preact/signals-react';

export interface UserSession {
  readonly id: string;
  readonly role: 'admin' | 'editor' | 'viewer';
  readonly permissions: readonly string[];
}

// 1. Сигнал с union-типом и null
const currentSession: Signal<UserSession | null> = signal<UserSession | null>(null);

// 2. Readonly вычисляемое свойство
const isAdmin: ReadonlySignal<boolean> = computed(() => {
  return currentSession.value?.role === 'admin';
});

// 3. Типобезопасный мутатор
export const updatePermissions = (newPermissions: string[]): void => {
  if (!currentSession.value) return;

  currentSession.value = {
    ...currentSession.value,
    permissions: Object.freeze([...newPermissions])
  };
};

Атомарная реактивность с Jotai: альтернатива или дополнение?

Jotai реализует концепцию атомарного состояния (bottom-up approach), вдохновленную Recoil, но с минималистичным API и сильной интеграцией с системой типов TypeScript (поддерживается синтаксис TS 3.8+).

Если Preact Signals работают через прямую мутацию .value, то Jotai строго следует канонам React: иммутабельность, однонаправленный поток данных и полная совместимость с Concurrent Mode.

Примитивные и производные атомы (atom<T>)

В Jotai базовой единицей состояния является атом. Атом сам по себе не хранит значение — он служит ключом/конфигурацией, а состояние хранится внутри React Context (или инстанса Store).

import { atom } from 'jotai';

// Примитивный атом (тип PrimitiveAtom<number> выводится автоматически)
export const basePriceAtom = atom<number>(100);
export const taxRateAtom = atom<number>(0.2);

// Read-only производный атом (тип Atom<number>)
export const totalPriceAtom = atom((get) => {
  const base = get(basePriceAtom);
  const tax = get(taxRateAtom);
  return base + base * tax;
});

Паттерны строгой типизации асинхронных и read/write атомов в Jotai

Jotai позволяет создавать атомы с кастомной логикой записи и асинхронными вычислениями с сохранением строгих типов аргументов и возвращаемых значений:

import { atom } from 'jotai';

export interface CustomerProfile {
  id: string;
  name: string;
  balance: number;
}

export const profileAtom = atom<CustomerProfile | null>(null);

// Асинхронный Write-Only атом для пополнения баланса
export const topUpBalanceAtom = atom(
  null, // Первым аргументом null — атом ничего не возвращает при чтении
  async (get, set, amount: number) => {
    const current = get(profileAtom);
    if (!current) throw new Error('Пользователь не авторизован');
    if (amount <= 0) throw new Error('Сумма должна быть больше нуля');

    // Имитация сетевого запроса
    const updated: CustomerProfile = {
      ...current,
      balance: current.balance + amount
    };

    set(profileAtom, updated);
  }
);

Использование в компонентах:

import React from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import { profileAtom, topUpBalanceAtom } from './state';

export const ProfileCard: React.FC = () => {
  // Разделение чтения и записи исключает лишние ререндеры
  const profile = useAtomValue(profileAtom);
  const topUp = useSetAtom(topUpBalanceAtom);

  if (!profile) return <div>Загрузка профиля...</div>;

  return (
    <div>
      <h3>{profile.name}</h3>
      <p>Баланс: {profile.balance} </p>
      <button onClick={() => void topUp(500)}>Пополнить на 500 </button>
    </div>
  );
};

Сравнительный анализ: Preact Signals vs Jotai в enterprise-архитектуре

Критерий Preact Signals (@preact/signals-react) Jotai
Парадигма Мутабельная гранулярная реактивность (.value) Иммутабельный атомарный стейт
Ререндеры Минимальные (точечное обновление в обход VDOM) Ограничены компонентами, вызвавшими useAtomValue
React Concurrent / Transitions Требует аккуратности при мутациях вне батчей Нативная поддержка из коробки
SSR / Next.js Требует изоляции контекста инстансов Поддерживается через <Provider> на уровне запроса
Кривая обучения Низкая (простая ментальная модель) Средняя (необходимо понимать read/write функции)

Управление жизненным циклом и сборка мусора (memory leaks)

  • Preact Signals: Глобальные сигналы живут на протяжении всего времени работы вкладки браузера. Если сигнал подписывается на внешние источники (например, WebSocket) через effect(), незакрытые эффекты приводят к утечкам памяти. Обязательно сохраняйте функцию отписки const dispose = effect(...) и вызывайте ее при размонтировании модуля.
  • Jotai: Состояние атомов хранится в структурах типа WeakMap внутри инстанса Store. Если компонент размонтирован и на атом больше нет ссылок в дереве, сборщик мусора (GC) очищает выделенную память.

Работа в SSR и Next.js

При работе с SSR (Server-Side Rendering) в Next.js (App Router / Pages Router) глобальные сигналы могут стать причиной утечки данных между пользователями (cross-request state pollution), если они объявлены как глобальные синглтоны на уровне модуля Node.js.

  • Для Jotai эта проблема решается оборачиванием дерева в <Provider>: каждый серверный запрос получает изолированный Store.
  • Для Preact Signals при SSR критически важно инициализировать стейт внутри хуков жизненного цикла запроса или сбрасывать его перед формированием ответа клиенту.

Практический пример: построение типобезопасного реактивного модуля

Реализуем модуль оформления заказа со сложными вычислениями (скидки, валидация, расчет сумм) на базе сигналов.

1. Определение интерфейсов и сигналов

// types.ts
export interface OrderItem {
  readonly id: string;
  readonly title: string;
  readonly price: number;
  readonly quantity: number;
}

export interface PromoCode {
  readonly code: string;
  readonly discountPercent: number;
}
// orderState.ts
import { signal, computed, batch } from '@preact/signals-react';
import { OrderItem, PromoCode } from './types';

export const itemsSignal = signal<readonly OrderItem[]>([
  { id: 'item-1', title: 'Архитектурный аудит React', price: 15000, quantity: 1 },
  { id: 'item-2', title: 'Настройка CI/CD pipeline', price: 8000, quantity: 2 }
]);

export const activePromoSignal = signal<PromoCode | null>(null);

// Вычисляемая сумма без скидки
export const rawSubtotalSignal = computed<number>(() => {
  return itemsSignal.value.reduce((acc, item) => acc + item.price * item.quantity, 0);
});

// Вычисляемая итоговая сумма со скидкой
export const totalAmountSignal = computed<number>(() => {
  const subtotal = rawSubtotalSignal.value;
  const promo = activePromoSignal.value;
  if (!promo) return subtotal;
  return subtotal * (1 - promo.discountPercent / 100);
});

// Валидация оформления заказа
export const isOrderValidSignal = computed<boolean>(() => {
  return itemsSignal.value.length > 0 && totalAmountSignal.value > 0;
});

// Действие: обновление количества товара
export const updateQuantity = (id: string, delta: number): void => {
  itemsSignal.value = itemsSignal.value
    .map((item) => {
      if (item.id === id) {
        const nextQty = Math.max(0, item.quantity + delta);
        return { ...item, quantity: nextQty };
      }
      return item;
    })
    .filter((item) => item.quantity > 0);
};

// Действие: сброс заказа с пакетным обновлением (batching)
export const resetOrder = (): void => {
  batch(() => {
    itemsSignal.value = [];
    activePromoSignal.value = null;
  });
};

2. UI-компоненты

// OrderSummaryView.tsx
import React from 'react';
import { 
  itemsSignal, 
  rawSubtotalSignal, 
  totalAmountSignal, 
  isOrderValidSignal, 
  updateQuantity,
  resetOrder 
} from './orderState';

export const OrderSummaryView: React.FC = () => {
  return (
    <div style={{ padding: '24px', maxWidth: '600px' }}>
      <h2>Корзина заказа</h2>

      <ul>
        {itemsSignal.value.map((item) => (
          <li key={item.id} style={{ marginBottom: '8px' }}>
            <span>{item.title}  {item.price}  x {item.quantity} шт.</span>
            <button onClick={() => updateQuantity(item.id, 1)} style={{ marginLeft: '8px' }}>+</button>
            <button onClick={() => updateQuantity(item.id, -1)} style={{ marginLeft: '4px' }}>-</button>
          </li>
        ))}
      </ul>

      <hr />

      <p>Промежуточный итог: <strong>{rawSubtotalSignal.value} </strong></p>
      <p>К оплате со скидкой: <strong>{totalAmountSignal.value} </strong></p>

      <div style={{ display: 'flex', gap: '12px', marginTop: '16px' }}>
        <button 
          disabled={!isOrderValidSignal.value}
          onClick={() => alert(`Заказ оформлен на сумму ${totalAmountSignal.value} ₽`)}
        >
          Оформить заказ
        </button>
        <button onClick={resetOrder}>Очистить</button>
      </div>
    </div>
  );
};

Частые ошибки при переходе на сигналы и как их избежать

  1. Прямая деструктуризация сигналов:

    • Ошибка: const { value } = counterSignal; в теле компонента. Деструктуризация извлекает статическое значение примитива на момент выполнения строки. Реактивная связь теряется.
    • Решение: Всегда обращайтесь к свойству .value напрямую по ссылке на сам сигнал внутри JSX или эффектов.
  2. Мутация вложенных объектов без изменения ссылки:

    • Ошибка: userSignal.value.address.city = 'Москва';
    • Решение: Сигналы сравнивают новое и старое значение по ссылочному равенству (Object.is). При работе со сложными объектами создавайте новый экземпляр:
      userSignal.value = { ...userSignal.value, address: { ...userSignal.value.address, city: 'Москва' } };
  3. Игнорирование batch() при множественных обновлениях:

    • Ошибка: Последовательное изменение нескольких сигналов подряд без группировки вызывает лишние синхронные срабатывания зависимых effect().
    • Решение: Используйте batch(() => { /* мутации */ }) для объединения цепочки изменений в одну транзакцию.
  4. Путаница между пакетами импорта:

    • Ошибка: Импорт signal из @preact/signals вместо @preact/signals-react. Это приводит к сбою рантайма в стандартном React-приложении.

FAQ: Ответы на частые вопросы разработчиков

1. Обязательно ли настраивать Babel-плагин для работы @preact/signals-react?

Нет, не обязательно. Плагин @preact/signals-react-transform является рекомендованным инструментом, автоматизирующим подписку. Если вы не можете модифицировать конфигурацию сборщика, используйте хук useSignals() из подпакета @preact/signals-react/runtime непосредственно внутри компонентов.

2. В чем принципиальное отличие между атомом Jotai и сигналом Preact?

Сигнал Preact — это контейнер со встроенным отслеживанием через мутабельный геттер/сеттер .value. Он может обновлять DOM точечно. Атом Jotai — это декларативное описание части состояния; само значение хранится внутри Store (в контексте React), а обновление идет по канонической модели React-ререндеров с полной поддержкой Concurrent Features.

3. Ломают ли сигналы ментальную модель и правила хуков (Rules of Hooks)?

Нет. Сигналы можно читать вне компонентов, передавать как обычные JS-объекты и вызывать внутри циклов или условий if/else, так как они не зависят от внутреннего массива хуков React Fiber. Однако при использовании useSignals() сам хук должен подчиняться стандартным правилам React Hooks.

4. Как безопасно использовать сигналы в SSR (Next.js)?

Не объявляйте глобальные сигналы с мутабельными пользовательскими данными в корне модулей, разделяемых между запросами сервера. Создавайте сигналы в рамках скоупа запроса или используйте Jotai с изолированным <Provider> для каждого входящего HTTP-запроса.

5. Можно ли использовать Signals и Jotai вместе с Redux Toolkit в одном проекте?

Да. Это распространенная практика при постепенном рефакторинге и борьбе с техдолгом. Глобальное серверное состояние или тяжелые бизнес-процессы можно оставить в Redux Toolkit, а высокочастотные UI-состояния (поля ввода, координаты курсора, анимации, дашборды реального времени) выносить в Signals или Jotai для исключения лишних ререндеров.


Вывод

Гранулярная реактивность на базе сигналов и атомов устраняет архитектурную проблему каскадных ререндеров в React, сокращая необходимость в громоздких оптимизациях с useMemo и useCallback.

Выбор инструмента зависит от задач команды:

  • Preact Signals (@preact/signals-react) подходят для задач с критическими требованиями к частоте обновлений UI (графики, интерактивные редакторы, динамические формы) благодаря прямому доступу к .value и минимальному оверхеду.
  • Jotai выступает гибким решением, органично дополняющим архитектуру React и экосистему Next.js благодаря изолированным хранилищам и иммутабельной модели.

Строгая типизация в обоих случаях гарантирует предсказуемость состояния и чистоту кодовой базы при масштабировании enterprise-приложений.

Источники

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также