reactive-robot-logo
reactive-robot
reactive-robot for javascript/typescript is less than 20 lines of code, viewable on your screen. Here it is. Copy it, customize it and own it.
reactive-robot.ts

export type ObjectDataType<T> = Record<string, T>
export type ObserverFunctionType = <T>(name:string, data:ObjectDataType<T>)=>void
const observers = {} as Record<string, ObserverFunctionType>
const rr = {
  addObserver: (key:string, observerFunction:ObserverFunctionType) => {
    observers[key] = observerFunction;
  },
  removeObserver: (key:string) => {
    delete observers[key];
  },
  next: <T>(name:string, data?:ObjectDataType<T>) => {
    for (const j in observers) {
      observers[j](name, data || {})
    }
  },
}
export default rr

Below is the canonical counter example using reactive-robot. It is also available from github to clone as a vite-scaffolded starter template at test-rr-vite.

In reactive-robot, your store is whatever you make it. Easiest to make it a global object with keys representing your datatypes, but it is up to you. You can use multiples stores and group them however you want. This store just has a count property for this simple example.

store.ts

const store = {
  count:0,
}

Events are at the heart of reactive-robot. A convenient pattern is create an events.ts file, with String constants for your event names and an optional typescript interface to define the shape of your event payloads. Just make all fields optional and use them where you need to as you are creating events.

events.ts

export const COUNT_UPDATE='COUNT_UPDATE'
//put other events here

export interface EventData {
  updatedBy?:string
}
  

Component1 displays global count state and allows it to be updated with any negative or positive value.

Component1.tsx

import { useState } from 'react'
import rr from 'reactive-robot';
import store from './utils/store.ts';
import {COUNT_UPDATE} from './utils/events.ts';

function Component1() {
  const [update, setUpdate] = useState(0)
  const [incDecAmount, setIncDecAmount] = useState('1')
  const onEvent = (name:string)=>{
    switch(name){
      case COUNT_UPDATE:
        setUpdate(Date.now())//fake state update!
        break
    }
  }
  rr.addObserver('Component1', onEvent)
  const handleIncrementClick = () => {
    store.count = store.count += parseInt(incDecAmount)
    rr.next(COUNT_UPDATE, {updatedBy:'Component1'})
  }
  return (
    <section data-rr-timestamp={update} className='component'>
      <span>Component1:</span>
      <span>count:{store.count}</span>
      <span>Increment/Decrement Amount</span>
      <input type='number' value={incDecAmount} onChange={(evt) => {
        setIncDecAmount(evt.target.value)
      }}></input>
      <button className='updateButton' onClick={handleIncrementClick}>update</button>
    </section>
  )
}
export default Component1

Component2 is like Component1, but it displays a local updatedBy to show the source of the last count update.

Component2.tsx

import { useState } from 'react'
import rr from 'reactive-robot';
import store from './utils/store.ts';
import {COUNT_UPDATE, type EventData} from './utils/events.ts';

function Component2() {
  const [update, setUpdate] = useState(0)
  const [updatedBy, setUpdatedBy] = useState('')
  const [incDecAmount, setIncDecAmount] = useState('1')
  const onEvent = (name:string, data:EventData)=>{
    switch(name){
      case COUNT_UPDATE:
        setUpdatedBy(data.updatedBy || '')
        setUpdate(Date.now())//fake state update!
        break
    }
  }
  rr.addObserver('Component2', onEvent)
  const handleIncrementClick = () => {
    store.count = store.count += parseInt(incDecAmount)
    rr.next(COUNT_UPDATE, {updatedBy:'Component2'})
  }
  return (
    <section data-rr-timestamp={update} className='component'>
      <span>Component2:</span>
      <span>count:{store.count}</span>
      <span>Increment/Decrement Amount</span>
      <input type='number' value={incDecAmount} onChange={(evt) => {
        setIncDecAmount(evt.target.value)
      }}></input>
      <button className='updateButton' onClick={handleIncrementClick}>update</button>
      <span className='updatedByLabel'>{'updated by:'+updatedBy}</span>
    </section>
  )
}
export default Component2

When increment is clicked in either component, the count will be updated in the store and a COUNT_UPDATE event is sent. Both components are receiving the COUNT_UPDATE event and doing a fake state update to rerender. The attribute data-rr-timestamp displays the timestamp of when the render occurs. Because the store is defined by the user and not used internally by reactive-robot, there are no restrictions on the data types you can use, or how they are accessed and updated. In reactive-robot, deeply nested objects are just as easy to deal with as top level primitives. You just need to define them for typescript like anything else.

You might notice the use of an object/hashtable in reactive-robot. Why not an array for observers? The reactive-robot observers list is intentionally a plain object so that if any observer is added multiple times, it will simply replace the previously added observer with the same name. With react functional components, this is likely to happen. This is why you need to call addObserver in the component function, so that each time the component is rerendered, it is adding the newly created onEvent observer function to reactive-robot. Do not add your onEvent observer to rr.addObserver in a useEffect hook. It will not work as expected. This is because reactive-robot will have a version of onEvent that was current when useEffect was called. If the component rerenders without calling useEffect, it will recreate its onEvent function which will then be out of sync with the version of onEvent that reactive-robot is referencing.