How to make a reactivity system
Last updated:
•read time: 5 min
In this tutorial, we’ll explore why the reactivity in modern JavaScript frameworks like React and Vue isn’t magic but just clever engineering.
Before anything — what is a reactivity system?
A reactive system in JavaScript is like magic for your UI — when your data (state) changes, the DOM updates automatically. No manual DOM edits.
What makes it reactive?
- State management — stores your app's data
- Dependency tracking — knows what depends on what
- Effects — code that reacts to state changes
- Lifecycle methods — run code when app mounts or unmounts
Setup (explanation)
Let’s prep the core structure of our reactive system.
🔐 Core storage:
- stateStore — holds all state variables
- reactiveEffects — holds all side effects
- mountCallbacks — stores functions for mount phase
- destroyCallbacks — stores cleanup functions
⚙️ Functions:
- useState() — define a reactive variable
- useEffect() — run code when state changes
- onMount() & onDestroy() — handle lifecycle
- render() — mounts your app
- reRenderApp() — re-renders the app on state change
- triggerEffects() — runs effects tied to a specific state
- stateCursor — tracks the current state slot
Setup (code)
script.js
const stateStore = []
const reactiveEffects = []
const mountCallbacks = []
const destroyCallbacks = []
// Used to re-render the app
let reRenderApp = null
const triggerEffects = (id, oldValue, newValue) => {
reactiveEffects.forEach(effect => {
if (effect.dependencies.includes(id)) {
effect.callback(oldValue, newValue)
}
})
}
// Track position in the state store
let stateCursor = 0
export const useState = (...) => {}
export const useEffect = (...) => {}
export const render = (...) => {}
export const onMount = (...) => {}
export const onDestroy = (...) => {}
State management
We’re going with a React-style pattern:
script.js
const [state, setState, stateId] = useState(initialValue)
How it works:
- useState() saves the initial value in stateStore
- Returns:
- The current value
- A setter to update it
- A unique ID for effects tracking
script.js
const useState = (initialValue) => {
const index = stateCursor
let stateId = stateStore[index]?.id
if (!stateStore[index]) {
const id = Date.now() + Math.random()
stateStore.push({ value: initialValue, id })
stateId = id
}
const currentValue = stateStore[index].value
const setValue = (newValue) => {
const prevValue = stateStore[index].value
if (newValue === prevValue) return
stateStore[index].value = newValue
triggerEffects(stateId, prevValue, newValue)
destroyCallbacks.forEach(fn => fn())
stateCursor = 0
reactiveEffects.length = 0
mountCallbacks.length = 0
destroyCallbacks.length = 0
reRenderApp?.()
}
stateCursor += 1
return [currentValue, setValue, stateId]
}
Reacting to changes with useEffect
useEffect() lets you react to a state change.
script.js
useEffect((oldVal, newVal) => {
console.log("State changed:", oldVal, newVal)
}, [stateId])
Internally, it’s simple:
script.js
export const useEffect = (callback, dependencies) => {
reactiveEffects.push({ callback, dependencies })
}
- You pass a callback and a list of state IDs
- The callback runs whenever one of them updates
Lifecycle methods: onMount & onDestroy
You might want to run code when the app mounts or before it re-renders.
onMount(callback)
- Runs after the first render
onDestroy(callback)
- Runs before re-render or unmount
Here's how they work:
script.js
export const onMount = (callback) => {
mountCallbacks.push(callback)
}
export const onDestroy = (callback) => {
destroyCallbacks.push(callback)
}
When render()
is called, it runs all mountCallbacks
. Before a re-render, it runs all destroyCallbacks
.
Rendering
Let’s connect everything to the browser using the render()
function.
script.js
export const render = (appFn, selector) => {
const target = document.querySelector(selector)
target.innerHTML = appFn()
reRenderApp = () => {
document.querySelector(selector).innerHTML = appFn()
mountCallbacks.forEach(fn => fn())
}
mountCallbacks.forEach(fn => fn())
}
Usage
Now let’s build a simple counter!
script.js
<div id="app"></div>
<script type='module'>
import { render, useState, useEffect, onMount } from './reactivity.js'
const App = () => {
const [count, setCount, countId] = useState(0)
const isTen = count === 10
useEffect((oldVal, newVal) => {
document.body.style.background = newVal > oldVal ? 'green' : 'red'
setTimeout(() => {
document.body.style.background = 'white'
}, 200)
}, [countId])
onMount(() => {
document.querySelector('.decreaseBtn')
.addEventListener('click', () => setCount(count - 1))
document.querySelector('.increaseBtn')
.addEventListener('click', () => setCount(count + 1))
})
return `
<h1>Count: ${count}</h1>
<button class="increaseBtn">increase +</button>
<button class="decreaseBtn">decrease –</button>
${isTen ? '<p>count is equal to 10</p>' : ''}
`
}
render(App, '#app')
</script>