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

React стал символом этой модели. Благодаря Fiber и Concurrent Mode он действительно ускорился, но его архитектура по‑прежнему опирается на дерево компонентов и диффинг виртуального DOM. Даже с умным планировщиком React всё ещё «пересчитывает дерево», а не конкретные зависимости данных.

На этом фоне появилась Signals  архитектура, которая предлагает другой путь: не оптимизировать старую модель, а избавиться от неё, сделав обновления атомарными и точечными. Без VDOM, ререндеров и догадок.

В этой статье мы разберём, чем «сигнальная реактивность» отличается от компонентной, и на реальных примерах из Solid.js и Angular Signals посмотрим, где именно проходит граница между «умным диффом» и «fine‑grained реактивностью».

Зачем нам вообще Signals

Фронтенд переживает очередной виток эволюции. Когда-то мы отказались от ручного управления DOM в пользу декларативного Virtual DOM, что дало нам выразительный синтаксис и избавило от микроменеджмента узлов. Но со временем выяснилось, что даже оптимизированный Virtual DOM тратит слишком много ресурсов, обновляя больше, чем действительно нужно.

Современные метрики производительности, такие как INP (Interaction to Next Paint) и TTI (Time to Interactive), требуют, чтобы JavaScript вмешивался в работу интерфейса как можно реже. Каждое лишнее вычисление или обновление DOM приводит к существенным задержкам для пользователя.

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

Чтобы понять, почему вообще понадобилось искать альтернативу Virtual DOM, посмотрим, как эволюционировал в последние десять лет сам React, главный двигатель этой парадигмы.

Эволюция React: VDOM, Fiber и планировщик

Классический VDOM + Diff (Ранний React)

Изначально React создавал виртуальное дерево, и при изменении состояния строил новое VDOM, сравнивал (диффил) его с предыдущим и применял патчи к реальному DOM. Это был синхронный и часто затратный процесс, но он дал разработчикам удобство декларативного описания интерфейса.

Fiber (React 16): новая внутренняя машина

Fiber не заменил VDOM, но стал совершенно новой архитектурой (реконсилером). Он разбил работу на мелкие, прерываемые единицы — «файберы».

Это позволило:

  • Приостанавливать и возобновлять рендеринг, не блокируя основной поток UI

  • Планировать приоритеты (например, пользовательский ввод важнее загрузки данных)

Fiber сделал React асинхронным и отзывчивым, но идея VDOM‑диффинга при рендере компонента сохранилась.

React 18 (Concurrent Features): умнее, но всё ещё дерево

React 18 принес такие концепции, как Concurrent Rendering и Transitions (startTransition). Эти механизмы сделали интерфейс более отзывчивым: теперь React способен откладывать менее приоритетные обновления, чтобы не блокировать взаимодействие с пользователем.

Но при всей этой «умности» важно понимать: единицей реактивности в React всё ещё остаётся компонент, а не конкретное значение. Когда меняется состояние, React вызывает повторный рендер всей функции компонента, даже если в DOM изменится только один текстовый узел. Fiber и VDOM Diff лишь помогают сократить последствия этой избыточности, но не устраняют её.

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

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

Архитектура на основе сигналов

Сигнал (Signal) это не просто переменная. Это реактивный примитив, который инкапсулирует значение и представляет собой миниатюрный, самодостаточный граф зависимостей.

Особенность

VDOM (React 18)

Signals (Solid/Angular)

Единица обновления

Компонент

Атомарное значение (Signal)

Механизм обновления

VDOM Diff & Reconciliation

Прямое обновление DOM-узла

Модель зависимостей

Неявная (через props/state)

Явная (зависимости трекаются при чтении)

Runtime Overhead

Выше (VDOM, Fiber)

Значительно ниже (нет VDOM)

Как работает атомарная реактивность

Работа Signals строится на трёх ключевых концепциях:

  1. Writable Signal (записываемый сигнал). Это сама переменная, имеющая два метода:

    • Чтение (.get() или count()): когда код читает значение сигнала, фреймворк «автоматически регистрирует» эту операцию, и читатель становится подписчиком (Subscriber)

    • Запись (.set()): когда значение меняется, сигнал уведомляет всех зарегистрированных потребителей

  2. Automatic Dependency Tracking (автоматический трекинг зависимостей). Это «магия» Signals. Фреймворк поддерживает глобальный контекст, следящий за тем, какая функция (или какой DOM‑эффект) выполняется в данный момент. Когда эта функция читает сигнал, она добавляется в список подписчиков этого сигнала.

    Пример: функция, которая обновляет DOM‑текст, читает count(). При первом запуске Signal трекает эту функцию и DOM-узел становится прямым подписчиком.

  3. Execution/Side-Effect (исполнение/побочный эффект). Это конечный потребитель сигнала. При изменении сигнала выполняется только та часть кода, которая на него подписана.

    • В React изменение состояния родительского компонента приводит к «перезапуску функции рендера» всего компонента. Затем VDOM Diff находит, что обновился только один текст.

    • В Signals изменение сигнала count приводит к выполнению только того DOM‑эффекта, который связан с отображением этого числа. Функция самого компонента остаётся не затронутой и не расходует ресурсы процессора на перезапуск.

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

    Чтобы не оставаться на уровне теории, посмотрим, как этот принцип реализован в реальных фреймворках: Solid.js (чистый fine‑grained подход) и Angular Signals (интеграция сигнальной модели в зрелую экосистему).

Пример Solid.js

В Solid компоненты‑функции выполняются только один раз при инициализации. Реактивность обеспечивают createSignal и автоматический трекинг:

import { createSignal } from "solid-js";

function Counter() {
    const [count, setCount] = createSignal(0);
    // count() читается и связывается с textNode
    return <button onClick={() => setCount(c => c + 1)}>{count()}</button>;
}
// При setCount(1), обновляется ТОЛЬКО текст в кнопке. Функция Counter не запускается.

Пример Angular Signals

Angular, традиционно использующий Zone.js для обнаружения изменений, теперь активно интегрирует Signals. Это позволяет фреймворку обнаруживать изменения точечно, минуя проверку всего дерева:

import { Component, signal } from '@angular/core';

@Component({
  template: `<button (click)="increment()">{{ count() }}</button>`
})
export class CountSignal {
  count = signal(0); 
  
  increment() { 
	  this.count.update(v => v + 1); 
  }
}
// При increment() обновляется ТОЛЬКО DOM-узел, связанный с count(). 
// Вся иерархия компонентов не проверяется, если они не зависят от сигнала.

Micro-benchmarks: реальное сравнение React и Signals

Ниже приведён код, который использовался при эксперименте. Вы можете провести его самостоятельно.

Angular
import { Component, signal } from '@angular/core';
import { CommonModule } from '@angular/common'; ` 
  
@Component({  
  selector: 'app-root',  
  standalone: true,  
  imports: [CommonModule],  
  template: `  
    <button (click)="updateRandom()">Update Random (Angular)</button>  
    <div class="grid">  
      @for(value of values; track value) {  
        <div class="item" >{{ value() }}</div>  
      }    
    </div>,  
  styles: `
    .grid { display: grid; grid-template-columns: repeat(20, 1fr); }    
    .item { padding: 2px; text-align: center; }  `
})
export class App {  
  values = Array.from({ length: 1000 }, () => signal(0));  
  
  updateRandom() {  
    const i = Math.floor(Math.random() * 1000);  
    this.values[i].update(value => value + 1);  
  }  
}
React
import React, { useState, Profiler, memo } from 'react';  
  
const renderCount = new Set();  
  
const Item = memo(function Item({ value }: { key: number; value: number; }) {  
    renderCount.add(Item);  
    return `<div className="item">{value}</div>`;   
});  
  
function App() {  
    const [data, setData] = useState(Array.from({ length: 1000 }, () => 0));  
  
    const updateRandom = () => {  
        const i = Math.floor(Math.random() * 1000);  
        setData(prev => {  
            const copy = [...prev];  
            copy[i] += 1;  
            return copy;  
        });  
    };  
  
    const onRender = (id, phase, actualDuration) => {  
        // Здесь мы увидим, что ререндерится только 1 компонент (тот, что изменился)  
        console.log(`React Profiler: Phase - ${phase}, Time - ${actualDuration.toFixed(2)}ms. Rerendered components: ${renderCount.size}`);  
        renderCount.clear();  
    };  
  
    return (  
        <Profiler id="App" onRender={onRender}>  
            <button onClick={updateRandom}>Update Random (React)</button>  
            <div className="grid">  
                {data.map((value, i) => <Item key={i} value={value} />)}  
            </div>  
        </Profiler>    
    );
}  
  
export default App;
Solid
import { createSignal, For } from "solid-js";  
  
function Item(props) {
    // Этот console.log отработает только 1 раз при создании компонента
    console.log('Solid Item component function executed'); 
    return <div class="item">{props.value()}</div>;
}  
  
function App() {  
    const [items, setItems] = createSignal(  
        Array.from({ length: 1000 }, () => createSignal(0))  
    );  
  
    const updateRandom = () => {  
        const i = Math.floor(Math.random() * 1000);  
        const [get, set] = items()[i];  
        set(get() + 1);  
    };  
  
    return (  
        <>  
            <button onClick={updateRandom}>Update Random (Solid)</button>  
            <div class="grid">  
                <For each={items()}>  
                    {([value]) => <Item value={value} />}  
                </For>  
            </div>        
        </>    
    );  
}  
  
export default App;

Рубрика «Эксперименты»

После написания всего тестового кода я провёл замеры на классическом сценарии update‑heavy: при каждом клике из тысячи элементов обновлялся один случайный счётчик. Для анализа использовался Chrome DevTools (вкладка Performance), в частности, поле Scripting Time, отражающее время выполнения JavaScript на CPU.

React не оптимизированный (без использования memo)

React не оптимизированный
React не оптимизированный

Изначально не оптимизированный React тратит 403 мс, что неудивительно, поскольку он перерисовывает все 1 тыс. компонентов.

Оптимизированный React (с использованием memo)

Оптимизированный React
Оптимизированный React

Однако даже после оптимизации с помощью React.memo длительность скриптинга React сократилась лишь до 205 мс.

React Profiler

React Profiler
React Profiler

При этом наш React Profiler показал, что фактическая длительность активного рендеринга (Actual Duration) составляла всего около 40 мс.

Angular Perfomance

Angular Perfomance
Angular Perfomance

Solid Perfomance

Solid Perfomance
Solid Perfomance

Как видите, в Angular и Solid длительность Scripting значительно меньше.

Почему оптимизированный React (205 мс) всё равно в 15—68 раз медленнее, чем Signals (3—14 мс)

Разница примерно в 191 мс (если брать значение Angular — 14 мс) — это накладные расходы (Overhead), которые Signals полностью исключают:

  1. Проверка memo. Даже при использовании React.memo фреймворк всё равно вынужден пройтись по всему дереву компонентов: вызвать App, обойти все 1 тыс. <Item> и для каждого сравнить свойства (prevProps.value === nextProps.value). Это тысячи проверок, которые почти никогда ничего не меняют, но тем не менее отнимают время процессора.

  2. VDOM/Fiber Overhead. Остальные миллисекунды уходят на внутренние процессы React: планирование задач, работу Fiber-цикла и поддержание структур Virtual DOM. Всё это приводит к задержке, даже если обновился только один элемент.

В результате даже оптимизированный React «платит за проверку» всего дерева, потому что мы 1 тыс. раз проверяем текст и один раз обновляем его. А сигналы «платят только за обновление» (один раз обновляем текст). В этом и заключается фундаментальная разница и главный выигрыш fine‑grained реактивности.

Является ли Signals серебряной пулей

Несмотря на очевидные преимущества в производительности, Signals не являются панацеей. У них есть свои компромиссы, особенно в сравнении с огромной экосистемой React:

  • Новая ментальная модель (DX): разработчики, привыкшие к императивным хукам React (useState, useEffect), должны привыкать к явному трекингу зависимостей Signals. Неправильное чтение сигнала вне контекста трекинга может не создать подписку, что приведёт к неожиданному поведению.

  • Экосистема и Tooling: React обладает самой большой и зрелой экосистемой (DevTools, хуки для оптимизации, готовые библиотеки), что критически важно для больших команд. Signal-фреймворки активно развиваются, но пока проигрывают в зрелости.

  • SSR, гидратация и «возобновляемость» (Resumability): реактивность — это не только обновления на клиенте. Современные приложения всё чаще требуют эффективной работы и на серверной стороне, особенно при рендеринге и загрузке.

    Когда мы говорим о Server‑Side Rendering (SSR), мы имеем дело с проблемой гидратации.

    Гидратация (Hydration)  процесс, при котором браузер получает от сервера готовый HTML‑код (чтобы показать контент быстро), а затем загружает весь JavaScript, чтобы «оживить» этот HTML, привязать к нему обработчики событий и восстановить состояние. Пока JavaScript не загружен и гидратация не завершена, пользовательский интерфейс может быть неинтерактивным. Гидратация — процесс дорогой, требующий времени CPU.

    • Решение от VDOM (React): React использует гидратацию. Он загружает весь код, строит VDOM-дерево в браузере и сверяет его с HTML от сервера.

    • Решение от Signals (Qwik, Solid): хотя Solid и Angular демонстрируют преимущество Signals в runtime, другие фреймворки пошли ещё дальше. Например, Qwik использует ту же идею реактивных сигналов, но применяет её на уровне загрузки и восстановления состояния приложения (Resumability).

      Суть в том, что на серверной стороне фреймворк не только генерирует HTML, но и сериализует состояние приложения и местонахождение всех подписчиков (сигналов) прямо в HTML.

Что даёт Resumability

Браузеру не нужно запускать весь JavaScript, чтобы «оживить» приложение. Он загружает только минимально необходимый код: маленький загрузчик, позволяющий возобновить работу с того места, где остановился сервер. Компоненты при этом загружаются лениво, только при первом взаимодействии пользователя (например, при клике на кнопку). Это устраняет «простой» CPU и значительно улучшает метрику TTI (Time To Interactive).

Стоит отметить, что миграция на React Server Components (RSC) тоже может быть непростой, особенно если вы планируете использовать компоненты на основе сигналов.

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

Когда стоит внедрять Signals

Реактивность на основе сигналов стоит рассматривать в проектах, где производительность интерфейса является критическим фактором:

  • Приложения с множеством интерактивных элементов (дашборды, графики, таблицы)

  • Сценарии в реальном времени, в которых обновления происходят десятки раз в секунду

  • Интерфейсы с ограниченными ресурсами (мобильные, embedded, SmartTV)

В этих случаях fine-grained реактивность даёт ощутимый выигрыш без сложных оптимизаций и ручной настройки.

Если же в проекте важнее стабильность экосистемы, поддержка tooling и большая команда, то React по-прежнему остаётся более предсказуемым и зрелым выбором.

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


  1. Saladin
    16.10.2025 09:38

    Спасибо, было интересно читать о новом сдвиге парадигмы во фронтенде!

    Интересно как реализуется механизм отписки от сигналов? Как фреймворк узнаёт, что данный сигнал больше не используется?


    1. alrout Автор
      16.10.2025 09:38

      там все работает через weakMap и weakRef + сборщик мусора, сигнал периодически ходит и смотрит живы еще активные ссылки или нет, если ссылка не активна - удаляет ее через сборщик мусора


      1. Saladin
        16.10.2025 09:38

        Хм, правильно ли я понимаю, что если у нас есть динамически загружаемые и выгружаемые компоненты(в Angular какой-нибудь RouterOutlet), то нельзя вручную почистить подписки на сигналы выгружаемого компонента(и всех подкомпонентов), а надо ждать сборщика мусора?


        1. alrout Автор
          16.10.2025 09:38

          то нельзя вручную почистить подписки на сигналы выгружаемого компонента

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


          1. Saladin
            16.10.2025 09:38

            Интересно, как работает эта магия. Для обычных компонентов с обычными реактивными Observables есть метод unsubscribe, который можно вызвать в хуке OnDestroy или ещё как-нибудь. Вопрос в том, как указать сигналу без использованя GC, что он больше не нужен. Метода unsubscribe у него как я понимаю нет.


            1. DmitryKazakov8
              16.10.2025 09:38

              Если речь про Solid, то там подписки на сигналы (createEffect) должны создаваться внутри компонентов, тогда при размонтировании компонентов они очищаются. Вот здесь описан механизм. Если там же объявлены сигналы, то просто пропадает ссылка на них и это собирается GC, как если в коде написать `{ data: 1 }` без присвоения переменной - как правило это очень быстро движками удаляется из памяти.

              А вот если createEffect объявлен за пределами компонентов, то насколько я знаю ручного dispose нет, и он будет существовать вечно. Иногда это и нужно - например, глобальные эффекты, завязанные на глобальные реактивные сущности (например, при изменении сигнала title нужно его значение ставить в document.title).


      1. nihil-pro
        16.10.2025 09:38

        ам все работает через weakMap и weakRef + сборщик мусора

        Нет конечно. WeakMap не иттерируемая коллекция, соответственно нельзя будет пройтись по списку и уведомить подписчиков. В теории, можно было бы обернуть подписчика в WeakRef, и класть в WeakMap -> WeakRef. Но на практике это бесполезно. Приведет к утечкам памяти из-за самих рефах внутри WeakMap-ов, даже если в самих рефах уже нет объекта. А еще WeakRef очень медленные при создании, и в целом добавляют достаточно много лишней работы GC.


        1. alrout Автор
          16.10.2025 09:38

          Вот здесь в статье автор разбирает, как работают сигналы и он четко указывает на WeakRef


          1. nihil-pro
            16.10.2025 09:38

            и он четко указывает на WeakRef

            Ок. Как я писал выше: «А еще WeakRef очень медленные при создании, и в целом добавляют достаточно много лишней работы GC.». Собственно поэтому эти сигналы проигрывают в перформансе Preact-у в связке с Observable, хотя у преакта VDOM, а Observable для связки с Preact-ом дополнительно оборачивает каждый компонент в HOC, внутри которого используются хуки useRef, useEffect и useLayoutEffect! И даже в этом случае, это быстрее сигналов ангуляра.


  1. domix32
    16.10.2025 09:38

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

    так а что граф зависимостей теперь уже и не дерево и диффы как-то иначе считаются? Кажется сигналы просто помечают, что текущая нода "грязная" и требует перерендеринга. В остальном разве, что обход графа вероятно короче, но это не точно.

    с помощью React.memo длительность скриптинга React сократилась лишь до 205 мс.

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


    1. alrout Автор
      16.10.2025 09:38

      граф зависимостей теперь уже и не дерево и диффы как-то иначе считаются? В остальном разве, что обход графа вероятно короче, но это не точно.

      Ну тут по сути тот тоже dirty-checking, но не на уровне дерева компонентов, а на уровне зависимостей данных. Поэтому обход сильно короче, сигналы смотрят только связанные вычисления.

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

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


  1. winkyBrain
    16.10.2025 09:38

    Второй год удивляюсь тому, как разработчики в упор не видят или вовсе не хотят знать о существовании сигналов в реакт. При этом если же речь заходит о сигналах в ангуляре, то сразу живой интерес, благодарности. И это не только у вас в посте - на днях уже выходил подобный материал, и там тоже всем интересно, будто само ангуляр-комьюнити менее зашоренное и куда более охотно смотрит на новые вещи. В реакте же прибежали болезные в комменты рассказывать про свой MobX. Видимо, некоторые веще не исправить


  1. js2me
    16.10.2025 09:38

    Недавний реакт конф показал что сигналы это не серебряная пуля и не мана небесная, конечно непонятно какие разрабы реакта имели в виду сигналы.

    Уменьшить количество ререндеров и добиться гранулярных ререндеров можно добиться и так с помощью MobX, до сих пор не понимаю почему люди так боятся этой библиотеки, что вновь и вноаь изобретают что то )


    1. winkyBrain
      16.10.2025 09:38

      Как с помощью mobX:

      • изменить значение ноды в браузере(div'a, span'a), не являющейся отдельным компонентом?

      • изменить значение в родительском компоненте, не вызвав при этом рендер дочерних компонентов, не обёрнутых в memo?

      Вопросы риторические, потому что ответ на них - никак. А вот сигналы вполне себе способны на такое и даже на большее. Так что ваш тезис

      до сих пор не понимаю почему люди так боятся этой библиотеки, что вновь и вноаь изобретают что то

      Не соответствует действительности и является подменой понятий, так как причины изобретать конечно же есть, и никто не боится - нет смысла прикасаться к тому, что уже заведомо хуже. Правильно будет "почему пользователи mobX не хотят узнавать что-то новое, легковесное и более производительное, изо всех сил цепляясь за хорошо знакомое". В разработке иногда стоит смотреть вперёд


      1. nihil-pro
        16.10.2025 09:38

        Как с помощью mobX:

        • изменить значение ноды в браузере(div'a, span'a), не являющейся отдельным компонентом?

        • изменить значение в родительском компоненте, не вызвав при этом рендер дочерних компонентов, не обёрнутых в memo?

        Mobx при изменении значения в вашем стейте уведомляет подписчика. То что в реакте этот подписчик целый компонент, и по другому в реакте нельзя, он так устроен, и это приводит к ре-рендеру компонента целиком + его дочерних компонентов – проблема реакта а не Mobx. Возьмите сигналы из солида, начните их использовать в реакте и получите точно такое же поведение. Так что нет, это ваш тезис:

        Не соответствует действительности и является подменой понятий

        Правильно будет "почему пользователи mobX не хотят узнавать что-то новое, легковесное и более производительное, изо всех сил цепляясь за хорошо знакомое". В разработке иногда стоит смотреть вперёд

        Например как Mobx, у которого базовый реактивный примитив это Атом, который тот же Сигнал?


        1. winkyBrain
          16.10.2025 09:38

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


          1. nihil-pro
            16.10.2025 09:38

            в рамках поста о сигналах в реакте сделал невозможное

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


            1. winkyBrain
              16.10.2025 09:38

              Значит второй вариант) почему же не показываются логи о рендерах из дочерних компонентов, при изменениях в родительском? В мемо они не обёрнуты


              1. nihil-pro
                16.10.2025 09:38


          1. DmitryKazakov8
            16.10.2025 09:38

            Я вот вник в preact/signals-react просто для интереса, и никакого "невозможного" или чудес там нет. Возьмем простой пример

            const childOneSignal = signal(0);
            
            setTimeout(() => { childOneSignal.value++; }, 2000);
            
            function Child1() {
              return <div>Child1: {childOneSignal}</div>;
            }
            
            function App() {
              return <Child1 />;
            }

            Действительно, при изменении значения сигнала не рендерятся ни App, ни Child1. Потому что если сигнал использовать без value, то на него создается отдельный реактовый компонент (выжимка из кода библиотеки с сокращениями)

            function SignalValue() {
              const storeRef = useRef(createEffectStore());
              const store = storeRef.current;
              useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
              store._start();
              
              return store;
            }
            
            Object.defineProperties(Signal.prototype, {
              $$typeof: { configurable: true, value: Symbol.for("react.transitional.element") },
              type: { configurable: true, value: SignalValue },
              props: {
                configurable: true,
                get() { return { data: this }; },
              },
            });

            Видим, что сигнал через прототип пытается показаться Реакту обычным компонентом и дальше использует стандартные реактовые механизмы для самообновления. Так, при изменении value в store увеличивается счетчик

            {
              subscribe(onStoreChange) {
                onChangeNotifyReact = onStoreChange;
            
                return function () { version = (version + 1) | 0; };
              },
              getSnapshot() { return version; }
            }

            затем реакт с помощью useSyncExternalStore узнает об изменении и ререндерит этот компонент с сигналом.

            Если желания копать код нет - можете посмотреть React Dev Tools и убедиться, что на каждый {signal} в jsx был создан отдельный компонент с пропом data и описанными выше хуками.

            При анализе я заметил довольно неэффективный механизм ререндера этого компонента по перфомансу, и вполне вероятно, что аналогичный явный код на Mobx будет производительнее

            function Child1() {
              return (
                <div>
                  Child1: <Observer>{() => store.count}</Observer>
                </div>
              );
            }

            Также очевидны и недостатки неявного создания компонента через {signal} - это не будет работать в атрибутах className={signal} , так как получается, что в атрибут передается целый реактовый компонент. И есть немало других неудобств при написании кода, например {childOneSignal > 0 && childOneSignal} , разумеется, работать не будет.

            почему пользователи mobX не хотят узнавать что-то новое, легковесное и более производительное

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


      1. DmitryKazakov8
        16.10.2025 09:38

        Как с помощью mobX:

        • изменить значение ноды в браузере(div'a, span'a), не являющейся отдельным компонентом?

        • изменить значение в родительском компоненте, не вызвав при этом рендер дочерних компонентов, не обёрнутых в memo?

        Для этого есть стандартные механизмы JS DOM API + React useRef. Например:

        const store = observable({ content: 0 });
        
        setInterval(() => {
          store.content++;
        }, 2000);
        
        function App() {
          const spanRef = useRef();
          
          useEffect(() => {
            const disposer = autorun(() => {
        	  const newContent = String(store.content);
        	
        	  if (spanRef.current) {
        	    spanRef.current.textContent = newContent;
        	  }
        	});
        	
        	return () => disposer();
          }, []);
          
          return <>Span text: <span ref={spanRef} /></>
        }

        Думаю, это удовлетворяет всем требованиям - никаких ререндеров, изменение значения конкретной DOM-ноды. Разумеется, можно вынести механизм в кастомный хук и использовать в виде пары строк или встроить в VM.

        К сожалению, Реакт из-за VDOM очень ограничен в альтернативах и обойти его механизмы рендеринга другими путями непросто. Solid.js в этом плане намного более гибкий и близок к нативному JS, поэтому при использовании Solid.js+Mobx (хотя у него есть довольно неплохой аналог Mobx в виде createMutable) можно обойтись без прямого обращения к DOM-ноде и просто писать в jsx <span>{data.counter}</span>. То есть это ограничение Реакта, а не реактивных систем, какую бы вы ни использовали (Mobx тоже на сигналах сделан, просто называются Atom, потому что когда его делали выбрали немного другую терминологию, но суть та же).


  1. nihil-pro
    16.10.2025 09:38

    То есть, вы сравнили время выполнения скрипта в реакте, солиде и ангуляре, и назвали это ускорением рендера в 68 раз? Кликбэйт наше все, или вы действительно не понимаете разницы?

    На этом фоне появилась Signals  архитектура, которая предлагает другой путь: не оптимизировать старую модель, а избавиться от неё

    А какая была старая модель?

    потому что мы 1 тыс. раз проверяем текст и один раз обновляем его. А сигналы «платят только за обновление» (один раз обновляем текст)

    Потому что вы в родительском компоненте вызвали setState, что привело к перерендеру компонента, и (к сожалению, из-за кривой архитектуры реакта), к проверки всех дочерних. А в солиде этого нет, ре-рендер родительского компонента не ререндерит автоматически дочерние. При чем тут сигналы? Перенесите изменение в конкретный дочерний компонент, и скорость моментально вырастит.

    function Child({ item }) {
      const [state, setState] = useState(() => item);
      return (
        <div>
          <div>{state.name}</div>
          <button
            onClick={() =>
              setState((prev) => ({ ...prev, name: (prev.name += "!!!") }))
            }
          >
            Update
          </button>
        </div>
      );
    }
    
    function Parent() {
      const [state] = useState(() => makeData());
    
      return (
        <div>
          {state.map((item) => (
            <Child item={item} key={item.id} />
          ))}
        </div>
      );
    }

    Конечно, от родовых травм реакта это не спасает, но если вы с ним работаете, то надо знать врага в лицо.

    В этом и заключается фундаментальная разница и главный выигрыш fine‑grained реактивности.

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

    const signal1 = signal(1);
    const signal2= signal(1)
    const computedSignal = computed(() => signal1 + signal2);
    
    function Component() {
      return <div>{signal2 + computedSignal}</div>
    }
    
    setTimeout(() => {
      signal1.set(signal1.get() + 1);
      signal2.set(signal2.get() + 1);
    }, 1000)

    Сколько раз отрендерится компонент? Один? Два? Три? А если один, то каким образом, например, signal2, пропустит уведомление своего подписчика Component, потому что он только что уже был вызван сигналом signal1? А как он это поймет? Если один, то ре-рендер будет после того как оба сигнала изменились, и пересчитался computedSignal? А если нет то когда, и что нам отрендерит компонент? А кто это будет оркестрировать?

    За последний месяц это вроде четвертая статья на хабре про сигналы, и кажется сообщество выбрала нового мессию...


    1. alrout Автор
      16.10.2025 09:38

      А какая была старая модель?

      VDOM Diff

      Кликбэйт наше все

      Чуть-чуть приправили кликбейтом статью, никто не пострадал )

      Перенесите изменение в конкретный дочерний компонент, и скорость моментально вырастит.

      Я закинул твой код в тест, да, скорость скриптинга уменьшилась до 73мс, это меньше, чем было, но статья немного про другое, я пытаюсь показать, что есть механизм обнаружения изменения VDOM Diff, а есть Signals, что первый работает вот так, а второй вот так.

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

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

      Еще можно задуматься о том, сколько сил мы тратим на то, что бы заоптимизировать это все, что бы догнать по скорости сигналы, а сколько мы тратим используя сигналы (почти нисколько, все и так работает)

      кажется сообщество выбрала нового мессию

      Вчера был VDOM, сегодня сигналы, через годик другой будет что-то еще, это просто инструмент, с помощью которого мы выполняем поставленные задачи


      1. nihil-pro
        16.10.2025 09:38

        VDOM Diff

        Вы понимаете, что в солиде просто нет Virtual Dom, и поэтому он быстрее, и дело вовсе не в сигналах?

        Чуть-чуть приправили кликбейтом статью, никто не пострадал )

        Кроме вашей репутации.

        Ну да, как раз сигналы в плане уведомления подписчиков и обновления инфы в них наиболее эффективны

        Как раз своим комментарием я вам показал, что дело вовсе не в них.

        Еще можно задуматься о том, сколько сил мы тратим на то, что бы заоптимизировать это все

        Кто мы? Я не трачу. Беру свой Observable, если не требуется супер-перформанс, использую с React-ом (редко), а могу с Solid-ом. Но чаще использую с Preact-ом, он хоть и с VDOM, но гораздо шустрее React-а, что еще раз подтверждает ошибочность ваших выводов.


        1. Vlad_IT
          16.10.2025 09:38

          Кстати, в преакте очень круто использовать их preact signals, они его интегрировали хорошо, что если в рендере в html элемент в атрибуты или children добавить сигнал без распаковки .value, то его изменение не приведет к ререндеру компонента, будет точечно обновлен атрибут или текстовое содержимое. По перфу эта связка идеальна. И можно точечно переписывать react приложение на такую связку.


  1. dadhi
    16.10.2025 09:38

    Just use datastar js, damn it.


  1. ooko
    16.10.2025 09:38

    Если рендер списка вынести в отдельный компонент. В item передавать ссылку на значение. То получим то что хотели без всяких сигналов


    1. alrout Автор
      16.10.2025 09:38

      Я вынес как предложили, получился скриптинг 105-110 мс, что "немного" дольше, чем у сигналов. Если у вас получится дотащить оптимизацию данных до этих значений, тогда можем смело заявлять, что сигналы не нужны


      1. nihil-pro
        16.10.2025 09:38

        да, скорость скриптинга уменьшилась до 73мс, это меньше, чем было

        получился скриптинг 105-110 мс, что "немного" дольше, чем у сигналов

        Так 73 или 105? ))
        А что будет, если из примера с реактом вы уберете отсутствующие в примерах на ангуляре и солиде операции с сетом?

        // Раз
        renderCount.add(Item);
        
        // Два
        renderCount.clear();  

        Плюс уберете профайлер, который очевидно, накладывает свой overhead, при том не нужный, так как в итоге вы все равно смотрите время выполнения скрипта во вкладке performance?


        1. alrout Автор
          16.10.2025 09:38

          Скрытый текст
          import React, { useState, useRef, useEffect, useCallback } from "react";
          
          const Item = React.memo(function Item({item, registerUpdater,}: {
              item: { name: string; id: number };
              registerUpdater: (id: number, updater: () => void) => void;
          }) {
              const [state, setState] = useState(() => item);
          
              useEffect(() => {
                  registerUpdater(item.id, () => {
                      setState((prev) => ({ ...prev, name: prev.name + "!" }));
                  });
              }, [item.id, registerUpdater]);
          
              return (
                  <div>
                      <span>{state.name}</span>
                  </div>
              );
          });
          
          
          const ItemList = React.memo(function ItemList({data, registerUpdater,}: {
              data: { name: string; id: number }[];
              registerUpdater: (id: number, updater: () => void) => void;
          }) {
              return (
                  <div>
                      {data.map((item) => (
                          <Item
                              key={item.id}
                              item={item}
                              registerUpdater={registerUpdater}
                          />
                      ))}
                  </div>
              );
          });
          
          
          function Parent() {
              const [data] = useState(() =>
                  Array.from({ length: 1000 }, (_, i) => ({
                      name: `${i}`,
                      id: i,
                  }))
              );
          
              const updaters = useRef<Record<number, () => void>>({});
          
              const registerUpdater = useCallback((id: number, updater: () => void) => {
                  updaters.current[id] = updater;
              }, []);
          
              const updateRandom = () => {
                  const randomId = Math.floor(Math.random() * data.length);
                  updaters.current[randomId]?.();
              };
          
              return (
                  <div>
                      <button onClick={updateRandom}>
                          Обновить Случайный Элемент
                      </button>
                      <ItemList data={data} registerUpdater={registerUpdater} />
                  </div>
              );
          }
          
          export default function App() {
              return (
                  <Parent />
              );
          }

          Вот такой код использовал и вот такой результат получается, если что-то еще можно изменить что бы улучшить время scripting - подскажите )

          Так 73 или 105? ))

          111 )


      1. ooko
        16.10.2025 09:38

        дотащил до 25ms на 100к элементах


  1. Unnemed112
    16.10.2025 09:38

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


    1. alrout Автор
      16.10.2025 09:38

      Способ сравнения четко отражает сколько нужно времени на обновление 1000 данных в представленных инструментах. Есть например криптобиржи, которым требуется бесконечно быстро перерисовывать значения в "стакане", и представленные инструменты смогут в этом помочь без особой головной боли.

      Но если вы предложите тесты, которые помогут нам объективнее взглянуть на эти инструменты, я проведу тесты и напишу результаты )


      1. Unnemed112
        16.10.2025 09:38

        Мне больше интересно посмотреть на тесты, которые бы сравнивали скорость вмонтирования и отмонтирования узлов с большим количеством дочерних элементов. Например тех же списков фиксированной длинны. Т.к. большая часть задач, которые решают реакт либой это сайдбары, дропдауны, аккордеоны, модалки и т.п. Ваш пример про биржи, как мне кажется не совсем подходящий, потому что для максимальной скорости я бы выбрал кастомное решение заточенное на скорость или вообще canvas.


  1. ioleynikov
    16.10.2025 09:38

    Мне кажется, что скорость обновления UI на современных компьютерах не является проблемой. Куда более важно точное следование принципам независимости компонент MVC и поэтому я хочу сказать пару слов в пользу React. Эта система внесла очень существенное улучшение концепции MVC. Классическая схема предполагает, что модель передает изменения данных в представление и тем самым полностью нарушает принцип независимости компонент. В React представление автоматически отслеживает изменение состояний данных модели при помощи механизма событий и обновляет UI. Аналогично контроллер React следит за событиями UI и представлению нет дела до обработки действий пользователя. Это намного более строго обеспечивает независимость и позволяет разработчику модели полостью абстрагироваться от представлений.


    1. Vlad_IT
      16.10.2025 09:38

      Мне кажется, что скорость обновления UI на современных компьютерах не является проблемой

      Я вот не согласен совсем. Веб приложения современные тормозят, а могли бы не тормозить, если бы их делали вспоминая про перформанс. Архитектура важна, но она не важнее скорости, т.к. скорость важнее для продукта. Я много проводил АБ тестов в крупных компаниях, и всегда скорость влияет на продукт, даже 300мс могут сильно решать в продуктовых и финансовых метриках.

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

      React из коробки медленный, он весит много, он тяжёлый для рендера, он ужасно тяжёлый для SSR. Но все на нем пишут, поэтому тяжело его в продукте применять, так как сложно будет найти разработчиков. В этом смысле preact идеален - и быстрый и разработчики react на нем могут писать даже не заметив подмены.

      Архитектура это круто, но кому она нужна, если приложение тормозит?


      1. ioleynikov
        16.10.2025 09:38

        Я могу согласиться с вами. Если криво писать, то можно получить дикие задержки, но у React есть куча механизмов повышения эффективности работы. Правда нужна высокая квалификация программиста. Но я говорил немного о другом. Сложные проекты действительно трудно доводить до ума, занимаясь одновременно и моделью и UI и контроллером. Куда проще радикально разделить модель только на SQL, UI только на HTML, CSS и оставить задачу сшивания этих разных миров контроллеру, который можно сильно автоматизировать и стандартизовать частично используя ИИ кодинг.