Олег Марков
Реактивные значения reactive values в современных фреймворках
Введение
Реактивные значения (reactive values) лежат в основе большинства современных frontend‑фреймворков и многих библиотек для управления состоянием. Когда вы используете ref во Vue, signal в Angular, writable в Svelte, BehaviorSubject в RxJS или atom в jotai/ Recoil — вы фактически работаете с реактивными значениями, просто под разными именами.
Идея очень простая: у вас есть значение, и вы хотите, чтобы все связанные с ним вычисления и интерфейс автоматически обновлялись, когда это значение меняется. Как только появляется автоматическое обновление — это уже реактивность, а сами хранилища данных становятся реактивными значениями.
В этой статье я покажу, что такое реактивные значения на уровне идей, как они реализуются под капотом и как с ними работать в разных сценариях. Мы не будем привязываться строго к одному фреймворку, но для наглядности возьмем примеры на базе типичного API с концепциями signal/ref и computed/derived. Так вы сможете перенести понимание в любой стек.
Базовая идея реактивных значений
Что такое реактивное значение
Реактивное значение — это обертка над обычным значением, которая:
- Позволяет безопасно читать и изменять это значение.
- Умеет уведомлять всех «подписчиков» об изменении.
- Может автоматически запускать связанные вычисления или перерисовку интерфейса.
Давайте посмотрим на минималистичную реализацию на чистом JavaScript, чтобы вы увидели суть без магии фреймворков.
Простейшая реализация reactive value
Смотрите, я покажу вам, как можно реализовать простейшее реактивное значение руками:
// Простейшее реактивное значение
function createReactiveValue(initial) {
let value = initial // Текущее значение
const listeners = new Set() // Набор подписчиков
return {
get() {
// Вернуть текущее значение
return value
},
set(newValue) {
// Если значение не изменилось - ничего не делаем
if (Object.is(value, newValue)) return
value = newValue
// Оповещаем всех подписчиков об изменении
listeners.forEach((fn) => fn(value))
},
subscribe(fn) {
// Добавляем функцию-подписчика
listeners.add(fn)
// Возвращаем функцию отписки
return () => listeners.delete(fn)
}
}
}
Теперь давайте разберемся на примере, как это использовать:
const count = createReactiveValue(0)
// Подписываемся на изменение
const unsubscribe = count.subscribe((newValue) => {
console.log('Новое значение счетчика:', newValue)
})
// Меняем значение
count.set(1) // В консоль выведется - Новое значение счетчика 1
count.set(2) // В консоль выведется - Новое значение счетчика 2
// Отписываемся
unsubscribe()
count.set(3) // Никаких логов - подписчик отписан
Как видите, этот код выполняет три ключевые задачи: хранит значение, позволяет его менять и уведомляет всех заинтересованных.
Большинство популярных фреймворков делают примерно то же самое, только дополняют это:
- отслеживанием зависимостей,
- мемоизацией вычислений,
- интеграцией с рендерингом,
- оптимизацией производительности.
Чтение, запись и подписка на реактивные значения
API чтения и записи
В разных библиотеках API может выглядеть по-разному:
- Vue:
const count = ref(0)— чтениеcount.value, записьcount.value = 1 - Angular (signals):
const count = signal(0)— чтениеcount(), записьcount.set(1) - SolidJS:
const [count, setCount] = createSignal(0)— чтениеcount(), записьsetCount(1) - Svelte:
const count = writable(0)— чтение через подписку или$countв шаблоне, записьcount.set(1) - RxJS:
const count$ = new BehaviorSubject(0)— чтениеcount$.value, записьcount$.next(1)
Но концептуально это одинаково: есть механизм get и set.
Чтобы было проще, давайте использовать условный API формата:
signal(initialValue)— создать реактивное значениеvalue()— прочитатьvalue.set(newValue)— записатьvalue.subscribe(listener)— подписаться
Это не конкретный фреймворк, а обобщенная модель.
Вот пример:
// Условная реализация, похожая на Angular signals
function signal(initial) {
let value = initial
const listeners = new Set()
function read() {
return value
}
read.set = (next) => {
if (Object.is(value, next)) return
value = next
listeners.forEach((fn) => fn(value))
}
read.subscribe = (fn) => {
listeners.add(fn)
// Сразу вызываем подписчика с текущим значением
fn(value)
return () => listeners.delete(fn)
}
return read
}
Теперь вы увидите, как это выглядит в коде:
const count = signal(0)
// Чтение значения
console.log(count()) // 0
// Подписка
const stop = count.subscribe((v) => {
console.log('count изменился на', v)
})
// Запись
count.set(1) // В консоли - count изменился на 1
count.set(2) // В консоли - count изменился на 2
stop() // Отписываемся
Логика вокруг записи
Обратите внимание на важные детали при записи:
- Часто проверяется, изменилось ли значение реально (
Object.is(old, next)). - Подписчики вызываются только при реальном изменении — так экономятся лишние перерисовки.
- Подписчики вызываются синхронно или асинхронно — это сильно влияет на поведение системы.
Многие реактивные системы (Vue, Angular signals, Solid) вызывают подписчиков синхронно, но могут планировать обновление DOM асинхронно (батчинг). RxJS может давать гибкость через планировщики.
Вычисляемые реактивные значения (computed / derived)
Зачем нужны вычисляемые значения
Обычно вам мало просто хранить какие-то данные — вам нужно вычислять Derived Data: фильтрованные списки, форматированные строки, суммы, агрегаты.
Если вы будете считать все руками в обработчиках, логика быстро расползется. Реактивные библиотеки решают это через computed / derived значения.
Идея: у вас есть реактивные значения a и b, и вы хотите реактивное значение sum, которое всегда равно a + b.
Вместо того, чтобы каждый раз вручную перевычислять sum, вы объявляете:
- зависимость
sumотaиb, - функцию вычисления
sum.
Фреймворк сам отслеживает зависимости и пересчитывает sum, когда нужно.
Простая реализация computed
Покажу вам, как это реализовано на практике, на основе нашей функции signal:
function computed(getter) {
const value = signal(undefined) // Внутреннее реактивное значение
let cleanup = null // Функция для отписки от зависимостей (упрощенно)
function recompute() {
const newValue = getter()
value.set(newValue)
}
// Здесь мы просто один раз считаем значение.
// В реальных реализациях нужно отслеживать, от каких signal зависит getter.
recompute()
// Возвращаем только функцию чтения и подписку
const read = () => value()
read.subscribe = value.subscribe
return read
}
В реальных библиотеках computed умеют:
- автоматически регистрировать зависимости,
- пересчитываться только при изменении зависимостей,
- кэшировать результат до следующего изменения.
Давайте посмотрим, как это может выглядеть в более живом примере:
const firstName = signal('Иван')
const lastName = signal('Петров')
// Вычисляемое реактивное значение
const fullName = computed(() => {
// Здесь мы используем значения сигналов
return firstName() + ' ' + lastName()
})
// Подписываемся на изменение fullName
fullName.subscribe((v) => {
console.log('Полное имя:', v)
})
// Меняем исходные части
firstName.set('Сергей') // В идеале - пересчитается fullName
lastName.set('Иванов') // fullName снова пересчитается
Фреймворк сам понимает, что fullName зависит от firstName и lastName, и пересчитывает результат только тогда, когда это действительно нужно.
Автоматическое отслеживание зависимостей
Как система понимает, от чего зависит вычисление
Самая «магическая» часть реактивных систем — это трекинг зависимостей. Но если посмотреть внимательнее, идея довольно понятна.
Чаще всего применяют такой прием:
- Есть глобальная переменная
activeEffect, в которой хранится текущая реактивная «реакция» (эффект, computed и т.п.). - Когда эффект выполняется, мы устанавливаем
activeEffectна него. - При чтении любого сигналa (reactive value) смотрим: если
activeEffectне пустой — значит, этот сигнал является зависимостью текущего эффекта. - При изменении сигнала вызываем все эффекты, которые на него подписаны.
Давайте разберемся на примере упрощенной реализации эффектов.
Реализация эффекта (effect / autorun)
Эффект — это функция, которая автоматически запускается, когда меняются ее реактивные зависимости.
let activeEffect = null
function effect(fn) {
const runner = () => {
activeEffect = runner // Устанавливаем текущий активный эффект
fn() // Выполняем пользовательский код
activeEffect = null // Сбрасываем активный эффект
}
runner.deps = new Set() // Множество зависимостей
runner() // Выполняем сразу один раз при создании
return runner
}
Теперь чуть изменим наш signal, чтобы он регистрировал эффекты:
function signal(initial) {
let value = initial
const listeners = new Set()
const dependents = new Set() // Эффекты, зависящие от сигнала
function read() {
// Если есть активный эффект - добавляем его в зависимости
if (activeEffect) {
dependents.add(activeEffect)
activeEffect.deps.add(dependents)
}
return value
}
read.set = (next) => {
if (Object.is(value, next)) return
value = next
// Оповещаем "слушателей" (прямых подписчиков)
listeners.forEach((fn) => fn(value))
// Запускаем все эффекты, которые зависят от этого сигнала
dependents.forEach((eff) => eff())
}
read.subscribe = (fn) => {
listeners.add(fn)
fn(value)
return () => listeners.delete(fn)
}
return read
}
Теперь давайте посмотрим, что происходит в следующем примере:
const count = signal(0)
const double = signal(0)
effect(() => {
// Этот эффект зависит от count
console.log('Текущее значение count:', count())
})
effect(() => {
// Этот эффект зависит и от count и от double
console.log('Сумма count + double:', count() + double())
})
// Изменяем значения
count.set(1) // Перезапустятся оба эффекта
double.set(10) // Перезапустится только второй эффект
Здесь важно понять: система автоматически запоминает, что второй эффект вызвал count() и double(), поэтому при изменении этих сигналов эффект переисполнится.
Реактивные значения и UI: связь с рендерингом
Привязка к DOM
На практике реактивные значения используются прежде всего для автоматического обновления пользовательского интерфейса.
Простой пример: вы хотите, чтобы содержимое div всегда соответствовало реактивному значению count.
Можно сделать что-то вроде (упрощенно, как в небольших фреймворках):
const count = signal(0)
const div = document.createElement('div')
document.body.appendChild(div)
// Эффект, который связывает DOM и реактивное значение
effect(() => {
// Каждый раз, когда count меняется, этот код выполняется заново
div.textContent = 'Счетчик - ' + count()
})
// Где-то в коде вы меняете значение
setInterval(() => {
count.set(count() + 1) // Интерфейс автоматически обновится
}, 1000)
Как видите, этот фрагмент кода решает задачу синхронизации состояния (count) с пользовательским интерфейсом без ручного управления DOM каждый раз.
Реальные фреймворки поверх этого слоя строят:
- виртуальный DOM (React, Vue),
- fine-grained обновления (Solid, Svelte, Angular signals),
- шаблоны и JSX.
Но фундамент — тот же: изменение реактивного значения триггерит пересчет части UI.
Батчинг и микротаски
Чтобы не перерисовывать интерфейс десятки раз при серии изменений, системы обычно используют батчинг обновлений:
- Изменения собираются в очередь.
- В конце микротаски или кадра (
requestAnimationFrame) фреймворк пересчитывает зависимости и обновляет DOM.
Вы это часто видите в практике: вы можете несколько раз установить значения, но UI обновится один раз.
Работа с массивами и объектами
Проблема «глубокой» реактивности
До сих пор мы рассматривали сигнал как обертку над примитивом. Но в реальных приложениях вы часто храните в состоянии:
- объекты (пользователь, настройки),
- массивы (списки элементов),
- вложенные структуры.
Основной вопрос: как система понимает, что user.name изменился? Есть два подхода:
- Глубокая (deep) реактивность — каждый вложенный путь отслеживается.
- Поверхностная (shallow) — значение объекта/массива считается единым целым.
Во Vue, например, используется прокси над объектами, которые отслеживают доступ к полям. В signals-подходе чаще применяют «иммутабельный» стиль: любое изменение структуры создает новый объект/массив.
Реактивный объект через signal
Давайте посмотрим простой вариант: мы храним объект в одном сигнале и меняем его иммутабельно.
const user = signal({
name: 'Иван',
age: 30
})
effect(() => {
// Эффект зависит от всего объекта user
const u = user()
console.log('Пользователь:', u.name, 'Возраст:', u.age)
})
// Меняем только имя - но меняем целиком объект
user.set({
...user(),
name: 'Петр'
})
Здесь важно: user.set вызывается с новым объектом, и все подписчики, использующие user(), будут уведомлены.
Локальная реактивность для полей
Иногда удобнее разбить объект на отдельные реактивные значения:
const userName = signal('Иван')
const userAge = signal(30)
effect(() => {
console.log('Имя пользователя:', userName())
})
effect(() => {
console.log('Возраст пользователя:', userAge())
})
// Меняем только имя - не трогаем возраст
userName.set('Петр')
Так вы более точно контролируете, какие части интерфейса обновляются при каких изменениях.
Работа с массивами
С массивами ситуация похожа: вы либо обновляете массив иммутабельно, либо используете специальные методы, которые внутри делают новый массив.
Давайте разберемся на примере списка задач:
const todos = signal([])
// Эффект, который реагирует на изменение списка
effect(() => {
console.log('Список задач:', todos())
})
// Добавление задачи
function addTodo(text) {
const current = todos()
todos.set([
...current, // Копируем старые задачи
{ id: Date.now(), text, done: false }
])
}
// Изменение флага done для задачи
function toggleTodo(id) {
const current = todos()
todos.set(
current.map(todo =>
todo.id === id
? { ...todo, done: !todo.done } // Создаем новый объект задачи
: todo
)
)
}
Здесь я размещаю пример с иммутабельными обновлениями: каждый раз создается новый массив, а не изменяется старый. Это упрощает трекинг изменений.
Потоки, асинхронность и реактивные значения
Реактивные значения и async/await
Один из распространенных кейсов — загрузка данных по сети и сохранение их в реактивное значение, чтобы интерфейс автоматически обновился.
Давайте посмотрим небольшой пример:
const isLoading = signal(false)
const error = signal(null)
const data = signal(null)
async function loadData() {
isLoading.set(true)
error.set(null)
try {
// Здесь мы выполняем запрос
const res = await fetch('/api/items')
if (!res.ok) {
throw new Error('Ошибка загрузки')
}
const json = await res.json()
data.set(json) // Сохраняем данные в сигнал
} catch (e) {
error.set(e.message || 'Неизвестная ошибка')
} finally {
isLoading.set(false)
}
}
effect(() => {
// Реагируем на смену состояния загрузки
console.log('isLoading:', isLoading(), 'error:', error())
})
effect(() => {
// Реагируем на обновление данных
console.log('Данные:', data())
})
Как видите, этот код выполняет типичный асинхронный сценарий: при изменении isLoading, error и data связанный интерфейс или эффекты автоматически обновляются.
Реактивные значения vs потоковые библиотеки (RxJS)
Реактивные значения (signals, ref и т.п.) — это, как правило, «последнее известное значение», к которому вы можете обратиться в любой момент.
В RxJS и других потоковых библиотеках:
- есть подписка на последовательность значений во времени,
- часто нет прямой синхронной функции для чтения последнего значения (кроме специальных типов вроде BehaviorSubject),
- много операторов для трансформаций (map, filter, switchMap и т.д.).
Реактивные значения ближе к «состоянию», а RxJS ближе к «потоку событий». Многие системы совмещают оба подхода: вы создаете поток, а затем «материализуете» его в реактивное значение.
Производительность и оптимизация реактивных значений
Зачем вообще думать о производительности
Реактивные системы удобны, но если бездумно создавать эффекты и зависимости, можно получить:
- лишние пересчеты,
- каскадные обновления,
- сложные для отладки циклы.
Поэтому полезно понимать несколько базовых паттернов оптимизации.
Мемоизация computed значений
Computed значения обычно кэшируются: если их зависимости не изменялись, результат не пересчитывается. Это экономит ресурсы при дорогих вычислениях.
Ваша задача как разработчика — стараться выносить дорогие расчеты в computed, а не в эффекты или прямо в рендер.
Например:
const items = signal([/* ... большой массив ... */])
// Плохой вариант - фильтрация внутри эффекта каждый раз
effect(() => {
const expensive = items().filter(/* ...сложный фильтр... */)
console.log('Результат:', expensive)
})
// Лучше - выделить это в computed
const filteredItems = computed(() => {
return items().filter(/* ...сложный фильтр... */)
})
effect(() => {
console.log('Результат:', filteredItems())
})
Второй вариант позволяет фреймворку контролировать частоту пересчета и не дублировать работу.
Разбиение состояния
Еще один прием — не хранить все в одном огромном реактивном объекте, если разные части редко связаны между собой.
Например, вместо:
const state = signal({
user: {...},
todos: [...],
settings: {...}
})
Можно сделать:
const user = signal({...})
const todos = signal([...])
const settings = signal({...})
Так эффекты, зависящие только от части состояния, не будут пересчитываться лишний раз.
Ошибки и типичные проблемы при работе с reactive values
Потеря реактивности из-за деструктуризации
Одна из частых ошибок — деструктуризация реактивного значения, когда вы забываете, что работаете не с обычным объектом, а с оберткой.
Например, во Vue:
const state = reactive({ count: 0 })
const { count } = state // Потеря реактивности для count
Или с сигналами:
const user = signal({ name: 'Иван', age: 30 })
const u = user()
// Дальше работа идет с u.name - это уже НЕ реактивное значение
Чтобы не терять реактивность, важно:
- либо использовать API фреймворка для извлечения полей (например, toRef, store.select и т.п.),
- либо не копировать значение без необходимости.
Создание циклических эффектов
Еще одна проблема — когда эффект внутри себя меняет сигнал, от которого зависит. Это приводит к бесконечным циклам.
Посмотрите на такой код:
const count = signal(0)
effect(() => {
const value = count()
if (value < 10) {
// Ошибка - эффект сам изменяет зависимость
count.set(value + 1)
}
})
Этот код начнет крутиться до тех пор, пока значение не станет 10, а в более сложных формах может уйти в бесконечный цикл.
Чтобы избежать подобного:
- не меняйте сигнал в том же эффекте, который от него зависит, без дополнительных условий и ограничений,
- используйте разделение: один эффект только читает, другой (например, обработчик события) только пишет.
Изменение массивов и объектов «по месту»
В иммутабельных подходах (React, многие signal-системы) нужно избегать мутаций по месту:
const list = signal([])
const arr = list()
arr.push(1) // Мутация по месту
list.set(arr) // Не всегда будет считаться «изменением»
Лучше всегда создавать новые массивы и объекты:
list.set([...list(), 1])
Примеры использования reactive values в разных контекстах
Пример 1: Счетчик с несколькими представлениями
Давайте рассмотрим простой, но показательный пример: у нас есть одно реактивное значение count, и мы хотим:
- показывать его «сырое» значение,
- выводить его в виде строки,
- хранить его умноженным на 2.
const count = signal(0)
// Вычисляемое значение как строка
const countLabel = computed(() => {
return 'Текущее значение счетчика - ' + count()
})
// Вычисляемое значение double
const doubleCount = computed(() => {
return count() * 2
})
// Эффект для "логирования"
effect(() => {
console.log(countLabel())
})
effect(() => {
console.log('Удвоенное значение:', doubleCount())
})
// Меняем одно исходное значение, а все представления пересчитываются автоматически
count.set(5)
count.set(10)
Здесь мы видим, что одно реактивное значение может порождать цепочку производных значений, и все вместе образуют «дерево вычислений».
Пример 2: Форма с валидацией
Теперь давайте посмотрим, что происходит в более жизненном примере — форме входа с валидацией.
const email = signal('')
const password = signal('')
// Простейшая валидация email
const isEmailValid = computed(() => {
const value = email()
return value.includes('@') && value.includes('.')
})
// Валидация пароля
const isPasswordValid = computed(() => {
return password().length >= 6
})
// Можно ли отправлять форму
const canSubmit = computed(() => {
return isEmailValid() && isPasswordValid()
})
// Эффект для отладки
effect(() => {
console.log('email valid:', isEmailValid(), 'password valid:', isPasswordValid())
})
effect(() => {
console.log('Кнопка "Отправить" доступна:', canSubmit())
})
// Дальше где-то в UI вы связываете поля ввода с сигналами
function onEmailInput(value) {
email.set(value)
}
function onPasswordInput(value) {
password.set(value)
}
Теперь при каждом вводе в поля:
- обновляются сигналы
emailиpassword, - пересчитываются
isEmailValidиisPasswordValid, - затем
canSubmit, - UI автоматически обновляет подсветку ошибок и состояние кнопки.
Заключение
Реактивные значения — это основа большинства современных решений для управления состоянием и построения интерфейсов. Внутри это всего лишь обертка над значением с возможностью подписки и автоматическим обновлением зависимых вычислений.
Ключевые идеи, которые важно удерживать:
- Любое реактивное значение всегда состоит из
get(чтение) иset(запись). - Под капотом почти всегда есть механизм эффекта (effect, autorun), который следит за зависимостями.
- Вычисляемые значения (computed, derived) помогают формировать производные данные и не дублировать логику.
- При работе с объектами и массивами проще и безопаснее использовать иммутабельный подход.
- На базе реактивных значений довольно легко строится автоматическая синхронизация с UI.
Поняв концепцию реактивных значений, вы сможете увереннее работать с любым фреймворком, где есть signals, refs, stores, atoms и прочие похожие сущности — все они опираются на одни и те же принципы.
Частозадаваемые технические вопросы по теме
Вопрос 1. Как правильно «дебаунсить» или «троттлить» реакции на изменение реактивного значения
Иногда реактивное значение меняется слишком часто, и вам нужно ограничить частоту реакции (например, при вводе в поле поиска).
Мини-инструкция:
- Не изменяйте сам сигнал, а оборачивайте реакцию (effect или подписку).
- Внутри эффекта вызывайте вашу функцию не напрямую, а через debounce/throttle.
Пример:
const query = signal('')
// Обычный debounce
function debounce(fn, delay) {
let timer = null
return (...args) => {
clearTimeout(timer)
timer = setTimeout(() => fn(...args), delay)
}
}
const search = debounce((q) => {
// Здесь запрос на сервер
console.log('Поиск по:', q)
}, 300)
effect(() => {
search(query()) // Дебаунс применяется к реакции, а не к сигналу
})
Вопрос 2. Как отписываться от эффектов при уничтожении компонента
Если фреймворк не управляет эффектами сам, вам нужно вручную вызывать очистку.
Мини-инструкция:
- Модифицируйте
effect, чтобы он возвращал функциюstop. - При уничтожении компонента вызывайте ее.
Пример:
function effect(fn) {
let active = true
const runner = () => {
if (!active) return
activeEffect = runner
fn()
activeEffect = null
}
runner()
return () => {
active = false
}
}
const stop = effect(() => {
console.log('Работаю пока компонент жив')
})
// При уничтожении
stop()
Вопрос 3. Как безопасно работать с асинхронными запросами, чтобы старый ответ не перезаписал новый
Типичная проблема — новый запрос выполнен, но старый, завершившийся позже, «перебивает» данные.
Мини-инструкция:
- Храните «номер версии» или токен запроса в реактивном значении.
- Перед записью результата сравнивайте, актуален ли запрос.
Пример:
const data = signal(null)
const requestId = signal(0)
async function load() {
const id = requestId() + 1
requestId.set(id)
const res = await fetch('/api/items')
const json = await res.json()
if (requestId() === id) {
data.set(json) // Обновляем только если это самый свежий запрос
}
}
Вопрос 4. Как объединять несколько реактивных значений в одно
Иногда нужно объединить несколько сигналов в «store».
Мини-инструкция:
- Создайте объект с полями-сигналами.
- Добавьте вспомогательные computed для комбинаций.
Пример:
const store = {
firstName: signal('Иван'),
lastName: signal('Петров'),
fullName: null
}
store.fullName = computed(() => {
return store.firstName() + ' ' + store.lastName()
})
Вопрос 5. Как тестировать код, использующий reactive values
Мини-инструкция:
- В юнит-тестах создавайте реальные сигналы.
- Явно вызывайте
setи проверяйте, что эффекты/вычисления ведут себя как ожидается. - Если эффекты автоматически запускаются, можно обернуть их в тестируемые функции.
Пример:
const count = signal(0)
let lastValue = null
effect(() => {
lastValue = count() * 2
})
count.set(2)
console.assert(lastValue === 4)
Постройте личный план изучения Vue до уровня Middle — бесплатно!
Vue — часть карты развития Frontend
100+ шагов развития
30 бесплатных лекций
300 бонусных рублей на счет
Бесплатные лекции
Все гайды по Vue
Лучшие курсы по теме

Vue 3 и Pinia
Антон Ларичев
TypeScript с нуля
Антон Ларичев