Коротко: Практическое руководство по 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()
Ядро сигналов базируется на трех примитивах:
signal(initialValue)— реактивный контейнер. Чтение свойства.valueвнутри контекста отслеживания автоматически регистрирует подписчика. Запись в.valueуведомляет только активных подписчиков.computed(fn)— мемоизированное производное значение. Функция выполняется лениво (только при запросе.value) и кеширует результат до тех пор, пока не изменится хотя бы один исходный сигнал внутри нее.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>
);
};
Частые ошибки при переходе на сигналы и как их избежать
Прямая деструктуризация сигналов:
- Ошибка:
const { value } = counterSignal;в теле компонента. Деструктуризация извлекает статическое значение примитива на момент выполнения строки. Реактивная связь теряется. - Решение: Всегда обращайтесь к свойству
.valueнапрямую по ссылке на сам сигнал внутри JSX или эффектов.
- Ошибка:
Мутация вложенных объектов без изменения ссылки:
- Ошибка:
userSignal.value.address.city = 'Москва'; - Решение: Сигналы сравнивают новое и старое значение по ссылочному равенству (
Object.is). При работе со сложными объектами создавайте новый экземпляр:
userSignal.value = { ...userSignal.value, address: { ...userSignal.value.address, city: 'Москва' } };
- Ошибка:
Игнорирование
batch()при множественных обновлениях:- Ошибка: Последовательное изменение нескольких сигналов подряд без группировки вызывает лишние синхронные срабатывания зависимых
effect(). - Решение: Используйте
batch(() => { /* мутации */ })для объединения цепочки изменений в одну транзакцию.
- Ошибка: Последовательное изменение нескольких сигналов подряд без группировки вызывает лишние синхронные срабатывания зависимых
Путаница между пакетами импорта:
- Ошибка: Импорт
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-приложений.




.svg.webp)





