Reactivity Patterns in Modern JavaScript

“Reactivity” is how systems respond to data updates. There are different types of reactivity, but for the purposes of this article, reactivity is when we do something in response to a change in data.

Reactivity Patterns Are Key to Web Development

We work with a lot of JS on websites and web applications because the browser is a completely asynchronous environment. We must respond to user actions, interact with the server, send reports, monitor performance, etc. This includes UI updates, network requests, browser navigation and URL changes, making cascading data updates a key aspect of web development.

Reactivity is usually associated with frameworks, but you can learn a lot by implementing reactivity in pure JS. We can mix and match with these patterns to better handle data updates.

Learning patterns results in less code and better performance in web applications, regardless of the framework used.

I like to study patterns because they apply to any language and system. Patterns can be combined to solve the problems of a particular application, often resulting in more performant and maintainable code.

Publisher/Subscriber

Publisher/Subscriber (PubSub) is one of the main reactivity patterns. Raising an event with publish() allows subscribers (subscribing to the event using subscribe()) respond to data changes:

const pubSub = {
  events: {},
  subscribe(event, callback) {
    if (!this.events[event]) {
      this.events[event] = []
    }
    this.events[event].push(callback)
  },
  publish(event, data) {
    if (this.events[event]) {
      this.events[event].forEach((callback) => {
        callback(data)
      })
    }
  },
  // Прим. пер.: автор почему-то считает, что у PubSub не должно быть этого метода
  unsubscribe(event, callback) {
    if (this.events[event]) {
      this.events[event] = this.events[event].filter((cb) => cb !== callback)
    }
  }
}

const handleUpdate = (data) => {
  console.log(data)
}
pubSub.subscribe('update', handleUpdate)
pubSub.publish('update', 'Some update') // Some update
pubSub.unsubscribe('update', handleUpdate)
pubSub.publish('update', 'Some update') // Ничего

Custom events – native browser interface for PubSub

The browser provides an API for calling and subscribing to custom events. Method dispatchEvent() allows not only to trigger an event, but also to attach data to it:

const pizzaEvent = new CustomEvent('pizzaDelivery', {
  detail: {
    name: 'Supreme',
  },
})

const handlePizzaEvent = (e) => {
  console.log(e.detail.name)
}
window.addEventListener('pizzaDelivery', handlePizzaEvent)
window.dispatchEvent(pizzaEvent) // Supreme

We can limit the scope of a custom event to any DOM node. In the given example, we have used the global object windowwhich is also known as the “global event bus” (event bus).

<div id="pizza-store"></div>
const pizzaEvent = new CustomEvent('pizzaDelivery', {
  detail: {
    name: 'Supreme',
  },
})

const pizzaStore = document.getElementById('pizza-store')
const handlePizzaEvent = (e) => {
  console.log(e.detail.name)
}
pizzaStore.addEventListener('pizzaDelivery', handlePizzaEvent)
pizzaStore.dispatchEvent(pizzaEvent) // Supreme

Custom Event Instances – Subclassing EventTarget

We can subclass the event target to send events to an instance of the class:

class PizzaStore extends EventTarget {
  constructor() {
    super()
  }
  addPizza(flavor) {
    // Вызываем событие прямо на классе
    this.dispatchEvent(
      new CustomEvent('pizzaAdded', {
        detail: {
          pizza: flavor,
        },
      }),
    )
  }
}

const Pizzas = new PizzaStore()
const handleAddPizza = (e) => {
  console.log('Added pizza:', e.detail.pizza)
}
Pizzas.addEventListener('pizzaAdded', handleAddPizza)
Pizzas.addPizza('Supreme') // Added pizza: Supreme

Our events are called on the class, not globally (on window). Handlers can connect directly to this instance.

Observer

The Observer pattern is similar to PubSub. It allows you to subscribe to the subject (Subject). To notify subscribers about data changes, the subject calls the method notify():

class Subject {
  constructor() {
    this.observers = []
  }
  addObserver(observer) {
    this.observers.push(observer)
  }
  removeObserver(observer) {
    this.observers = this.observers.filter((o) => o !== observer)
  }
  notify(data) {
    this.observers.forEach((observer) => {
      observer.update(data)
    })
  }
}

class Observer {
  update(data) {
    console.log(data)
  }
}

const subject = new Subject()
const observer = new Observer()

subject.addObserver(observer)
subject.notify('Hi, observer!') // Hi, observer!
subject.removeObserver(observer)
subject.notify('Are you still here?') // Ничего

Reactive Object Properties – Proxy

proxy allows you to provide reactivity when setting/getting object property values:

const handler = {
  get(target, property) {
    console.log(`Getting property ${property}`)
    return target[property]
  },
  set(target, property, value) {
    console.log(`Setting property ${property} to value ${value}`)
    target[property] = value
    return true // Индикатор успешной установки значения свойства
  },
}

const pizza = {
  name: 'Margherita',
  toppings: ['mozzarella', 'tomato sauce'],
}
const proxiedPizza = new Proxy(pizza, handler)

console.log(proxiedPizza.name) // 'Getting property name' и 'Margherita'
proxiedPizza.name="Pepperoni" // Setting property name to value Pepperoni

Reactivity of individual object properties

Object.defineProperty() allows you to define accessors (getters and setters) when defining an object property:

const pizza = {
  _name: 'Margherita', // Внутреннее свойство
}

Object.defineProperty(pizza, 'name', {
  get() {
    console.log('Getting property name')
    return this._name
  },
  set(value) {
    console.log(`Setting property name to value ${value}`)
    this._name = value
  },
})

console.log(pizza.name) // 'Getting property name' и 'Margherita'
pizza.name="Pepperoni" // Setting property name to value Pepperoni

Object.defineProperties() allows you to define accessors for several properties of an object at the same time.

Asynchronous Reactive Data – Promises

Let’s make our observers asynchronous! This will update the data and start the observers asynchronously:

class AsyncData {
  constructor(initialData) {
    this.data = initialData
    this.subscribers = []
  }

  // Подписываемся на изменения данных
  subscribe(callback) {
    if (typeof callback !== 'function') {
      throw new Error('Callback must be a function')
    }
    this.subscribers.push(callback)
  }

  // Обновляем данные и ждем завершения всех обновлений
  async set(key, value) {
    this.data[key] = value

    const updates = this.subscribers.map(async (callback) => {
      await callback(key, value)
    })

    await Promise.allSettled(updates)
  }
}

const data = new AsyncData({ pizza: 'Pepperoni' })

data.subscribe(async (key, value) => {
  await new Promise((resolve) => setTimeout(resolve, 1000))
  console.log(`Updated UI for ${key}: ${value}`)
})

data.subscribe(async (key, value) => {
  await new Promise((resolve) => setTimeout(resolve, 500))
  console.log(`Logged change for ${key}: ${value}`)
})

// Функция для обновления данных и ожидания завершения всех обновлений
async function updateData() {
  await data.set('pizza', 'Supreme') // Вызываем всех подписчиков и ждем их разрешения
  console.log('All updates complete.')
}

updateData()
/**
  через 500 мс
  Logged change for pizza: Supreme
  через 1000 мс
  Updated UI for pizza: Supreme
  All updates complete.
 */

Jet systems

Many popular libraries and frameworks are based on complex reactive systems: hooks (Hooks) in React, signals (Signals) in SolidJS, observable entities (Observables) in Rx.js, etc. As a rule, their main task is to re-render components or fragments of the DOM when the data changes.

Observables (rx.js)

The “Observer” pattern and Observables (which can be conditionally translated as “observable entities”) are not the same thing, as it might seem at first glance.

Observables allow you to generate (produce) a sequence (sequence) of values ​​over time. Consider a simple Observable primitive that sends a sequence of values ​​to subscribers, allowing them to respond to generated values:

class Observable {
  constructor(producer) {
    this.producer = producer
  }

  // Метод для подписки на изменения
  subscribe(observer) {
    // Проверяем наличие необходимых методов
    if (typeof observer !== 'object' || observer === null) {
      throw new Error('Observer must be an object with next, error, and complete methods')
    }

    if (typeof observer.next !== 'function') {
      throw new Error('Observer must have a next method')
    }

    if (typeof observer.error !== 'function') {
      throw new Error('Observer must have an error method')
    }

    if (typeof observer.complete !== 'function') {
      throw new Error('Observer must have a complete method')
    }

    const unsubscribe = this.producer(observer)

    // Возвращаем объект с методом для отписки
    return {
      unsubscribe: () => {
        if (unsubscribe && typeof unsubscribe === 'function') {
          unsubscribe()
        }
      },
    }
  }
}

Usage example:

// Создаем новый observable, который генерирует три значения и завершается
const observable = new Observable(observer => {
  observer.next(1)
  observer.next(2)
  observer.next(3)
  observer.complete()

  // Опционально: возвращаем функцию очистки
  return () => {
    console.log('Observer unsubscribed')
  }
})

// Определяем observer с методами next, error и complete
const observer = {
  next: value => console.log('Received value:', value),
  error: err => console.log('Error:', err),
  complete: () => console.log('Completed'),
}

// Подписываемся на observable
const subscription = observable.subscribe(observer)

// Опциональная отписка прекращает получение значений
subscription.unsubscribe()

Method next() sends data to observers. Method complete() closes the data stream (stream). Method error() designed for error handling. subscribe() allows you to subscribe to data, and unsubscribe() – unsubscribe from them.

The most popular libraries that use this pattern are Rx.js And MobX.

Signals (SolidJS)

Take a look at reactivity course with SolidJS by Ryan Carniato.

const context = []

export function createSignal(value) {
  const subscriptions = new Set()

  const read = () => {
    const observer = context[context.length - 1]
    if (observer) {
      subscriptions.add(observer)
    }
    return value
  }
  const write = (newValue) => {
    value = newValue
    for (const observer of subscriptions) {
      observer.execute()
    }
  }

  return [read, write]
}

export function createEffect(fn) {
  const effect = {
    execute() {
      context.push(effect)
      fn()
      context.pop()
    },
  }

  effect.execute()
}

Usage example:

import { createSignal, createEffect } from './reactive'

const [count, setCount] = createSignal(0)

createEffect(() => {
  console.log(count())
}) // 0

setCount(10) // 10

The complete example code can be found Here. You can read more about the signal here.

Observed Values ​​(Frontend Masters)

Our video player has many settings that can be changed at any time to modify video playback. Kai from our team developed observable-ish valueswhich is another example of a pure JS reactive system.

Observables are a combination of PubSub with computed values ​​that allow you to add results from multiple publishers.

An example of notifying a subscriber about a value change:

const fn = function (current, previous) {}

const obsValue = ov('initial')
obsValue.subscribe(fn) // подписка на изменения
obsValue() // 'initial'
obsValue('initial') // 'initial', изменений не было
obsValue('new') // fn('new', 'initial')
obsValue.value="silent" // тихое обновление

Modifying arrays and objects does not publish changes, but replaces them:

const obsArray = ov([1, 2, 3])
obsArray.subscribe(fn)
obsArray().push(4) // тихое обновление
obsArray.publish() // fn([1, 2, 3, 4]);
obsArray([4, 5]) // fn([4, 5], [1, 2, 3]);

Passing a function caches the result as a value. Additional arguments are passed to the function. Observable entities called in a function are subscribers, updating these entities causes the value to be recalculated.

If the function returns a promise, the value is assigned asynchronously after it resolves.

const a = ov(1)
const b = ov(2)
const computed = ov((arg) => {
  a() + b() + arg
}, 3)
computed.subscribe(fn)
computed() // fn(6)
a(2) // fn(7, 6)

Reactive UI rendering

Let’s take a look at some DOM and CSS reading and writing patterns.

Rendering Data with Template Literals

Template literals allow you to interpolate variables, making it easier to generate HTML templates:

function PizzaRecipe(pizza) {
  return `<div class="pizza-recipe">
    <h1>${pizza.name}</h1>
    <h3>Toppings: ${pizza.toppings.join(', ')}</h3>
    <p>${pizza.description}</p>
  </div>`
}

function PizzaRecipeList(pizzas) {
  return `<div class="pizza-recipe-list">
    ${pizzas.map(PizzaRecipe).join('')}
  </div>`
}

const allPizzas = [
  {
    name: 'Margherita',
    toppings: ['tomato sauce', 'mozzarella'],
    description: 'A classic pizza with fresh ingredients.',
  },
  {
    name: 'Pepperoni',
    toppings: ['tomato sauce', 'mozzarella', 'pepperoni'],
    description: 'A favorite among many, topped with delicious pepperoni.',
  },
  {
    name: 'Veggie Supreme',
    toppings: [
      'tomato sauce',
      'mozzarella',
      'bell peppers',
      'onions',
      'mushrooms',
    ],
    description: 'A delightful vegetable-packed pizza.',
  },
]

// Рендерим список
function renderPizzas() {
  document.querySelector('body').innerHTML = PizzaRecipeList(allPizzas)
}

renderPizzas() // Первоначальный рендеринг

// Пример изменения данных и повторного рендеринга
function addPizza() {
  allPizzas.push({
    name: 'Hawaiian',
    toppings: ['tomato sauce', 'mozzarella', 'ham', 'pineapple'],
    description: 'A tropical twist with ham and pineapple.',
  })

  renderPizzas() // Рендерим обновленный список
}

// Добавляем новую пиццу и повторно рендерим список
addPizza()

The main disadvantage of this approach is that the entire DOM is modified on every render. Libraries like lit-htmlallow you to update the DOM more intelligently when only modified parts are updated.

Reactive DOM Attributes – MutationObserver

One way to make the DOM reactive is to manipulate the attributes of HTML elements. MutationObserver API allows you to observe changes in attributes and react to them in a certain way:

const mutationCallback = (mutationsList) => {
  for (const mutation of mutationsList) {
    if (
      mutation.type !== 'attributes' ||
      mutation.attributeName !== 'pizza-type'
    )
      return

    console.log('Old:', mutation.oldValue)
    console.log('New:', mutation.target.getAttribute('pizza-type'))
  }
}
const observer = new MutationObserver(mutationCallback)
observer.observe(document.getElementById('pizza-store'), { attributes: true })

Note. trans.: MutationObserver allows you to observe not only the change in attributes, but also the change in the text of the target element and its child elements.

Reactive attributes in web components

Web Components provide a native way to watch attribute updates:

// Определяем кастомный элемент HTML
class PizzaStoreComponent extends HTMLElement {
  static get observedAttributes() {
    return ['pizza-type']
  }

  constructor() {
    super()
    const shadowRoot = this.attachShadow({ mode: 'open' })
    shadowRoot.innerHTML = `<p>${
      this.getAttribute('pizza-type') || 'Default content'
    }</p>`
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'pizza-type') {
      this.shadowRoot.querySelector('div').textContent = newValue
      console.log(`Attribute ${name} changed from ${oldValue} to ${newValue}`)
    }
  }
}

customElements.define('pizza-store', PizzaStoreComponent)
<!-- Добавляем кастомный элемент в разметку -->
<pizza-store pizza-type="Supreme"></pizza-store>
// Модифицируем атрибут `pizza-store`
document.querySelector('pizza-store').setAttribute('pizza-type', 'BBQ Chicken');

Reactive Scrolling – IntersectionObserver

IntersectionObserver API allows you to react to the intersection of the target element with another element or viewport:

const pizzaStoreElement = document.getElementById('pizza-store')

const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      entry.target.classList.add('animate-in')
    } else {
      entry.target.classList.remove('animate-in')
    }
  })
})

observer.observe(pizzaStoreElement)

See animation example when scrolling on CodePen.

Note. trans.: except MutationObserver And IntersectionObserverthere is one more native observer — ResizeObserver.

Animation looping – requestAnimationFrame

In game development, when working with Canvas or WebGL, animations often require writing to a buffer and then writing the results in a loop when a rendering thread becomes available. Usually, we implement this with requestAnimationFrame:

function drawStuff() {
  // Логика рендеринга игры или анимации
}

// Функция обработки анимации
function animate() {
  drawStuff()
  requestAnimationFrame(animate) // Продолжаем вызывать `animate` на каждом кадре рендеринга
}

// Запускаем анимацию
animate()

Reactive Animations – Web Animations

Web Animations API allows you to create reactive granular animations. An example of using this interface to animate the scale, position, and color of an element:

const el = document.getElementById('animated-element')

// Определяем свойства анимации
const animation = el.animate(
  [
    // Ключевые кадры (keyframes)
    {
      transform: 'scale(1)',
      backgroundColor: 'blue',
      left: '50px',
      top: '50px',
    },
    {
      transform: 'scale(1.5)',
      backgroundColor: 'red',
      left: '200px',
      top: '200px',
    },
  ],
  {
    // Настройки времени
    // Продолжительность
    duration: 1000,
    // Направление
    fill: 'forwards',
  },
)

// Устанавливаем скорость воспроизведения в значение `0`
// для приостановки анимации
animation.playbackRate = 0

// Регистрируем обработчик клика
el.addEventListener('click', () => {
  // Если анимация приостановлена, возобновляем ее
  if (animation.playbackRate === 0) {
    animation.playbackRate = 1
  } else {
    // Если анимация воспроизводится, меняем ее направление
    animation.reverse()
  }
})

The reactivity of such an animation lies in the fact that it can be played relative to the current position at the moment of interaction (as in the case of a direction change in the above example). Animations and CSS transitions do not allow this.

Reactive CSS – custom properties and calc

We can write reactive CSS with custom properties and calc:

barElement.style.setProperty('--percentage', newPercentage)

We set the value of the custom property in JS.

.bar {
  width: calc(100% / 4 - 10px);
  height: calc(var(--percentage) * 1%);
  background-color: blue;
  margin-right: 10px;
  position: relative;
}

And we make calculations based on this value in CSS. Thus, CSS is responsible for styling the element, as it should be.

You can read the current value of a custom property like this:

getComputedStyle(barElement).getPropertyValue('--percentage')

As you can see, modern JS allows you to achieve reactivity in many different ways. We can combine these patterns for reactive rendering, logging, animation, user event handling, and other things that happen in the browser.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *