Components that clean up
Components with timers and listeners, set up when their page arrives and cleaned up when it leaves, with a log of every step.
When Pagefold swaps the HTML, the JavaScript you attached to it stays behind: listeners, timers and instances of libraries. pagefold.mount creates a component for every element that matches a selector, and runs its cleanup when the element leaves.
main.js
const pagefold = new Pagefold()
const log = document.querySelector('#log')
const lines = document.querySelector('#lines')
const cleanupOn = document.querySelector('#cleanup')
let alive = 0
let timers = 0
let tabsCount = 0
const write = (text, kind) => {
const li = document.createElement('li')
li.className = kind
li.textContent = text
lines.prepend(li)
}
const show = () => {
document.querySelector('#alive').textContent = alive
document.querySelector('#timers').textContent = timers
log.classList.toggle('is-leaking', !cleanupOn.checked)
}
cleanupOn.addEventListener('change', show)
document.querySelector('#reset').addEventListener('click', () => location.reload())
// The function a component returns runs when its element leaves the page.
// With the switch off it does nothing, as if you had forgotten to write it.
const whenLeaving = (name, cleanup) => () => {
if (!cleanupOn.checked) return write(`NOT cleaned ${name}: it is still alive`, 'leak')
cleanup()
alive--
show()
write(`cleaned ${name}`, 'down')
}
pagefold.mount('[data-counter]', (el) => {
const button = el.querySelector('button')
const number = el.querySelector('b')
let n = 0
const onClick = () => (number.textContent = ++n)
button.addEventListener('click', onClick)
alive++
show()
write('set up counter', 'up')
return whenLeaving('counter', () => button.removeEventListener('click', onClick))
})
pagefold.mount('[data-clock]', (el) => {
const time = el.querySelector('.time')
const tick = () => (time.textContent = new Date().toLocaleTimeString([], { hour12: false }))
tick()
const timer = setInterval(tick, 1000)
alive++
timers++
show()
write('set up clock, timer started', 'up')
return whenLeaving('clock (its timer keeps running)', () => {
clearInterval(timer)
timers--
})
})
pagefold.mount('[data-tabs]', (el) => {
const buttons = [...el.querySelectorAll('.tablist button')]
const panel = el.querySelector('.panel')
const id = ++tabsCount
const onClick = (event) => {
buttons.forEach((b) => b.setAttribute('aria-selected', String(b === event.currentTarget)))
panel.textContent = `Panel ${event.currentTarget.textContent.toLowerCase()}`
}
// A listener on the document outlives the element, unless somebody removes it
const onKey = (event) => write(`key "${event.key}" handled by tabs #${id}`, 'up')
buttons.forEach((b) => b.addEventListener('click', onClick))
document.addEventListener('keydown', onKey)
alive++
show()
write(`set up tabs #${id}`, 'up')
return whenLeaving(`tabs #${id} (it still listens to the keyboard)`, () => {
buttons.forEach((b) => b.removeEventListener('click', onClick))
document.removeEventListener('keydown', onKey)
})
}) index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Counter · Components</title>
<meta name="description" content="A counter keeps its own number while you are here.">
<link rel="stylesheet" href="../_lib/fade.css">
<link rel="stylesheet" href="style.css">
</head>
<body>
<header class="top">
<strong>Components</strong>
<nav>
<a href="index.html">Counter</a>
<a href="clock.html">Clock</a>
<a href="tabs.html">Tabs</a>
</nav>
</header>
<main id="pagefold" class="transition-fade">
<h1>Counter</h1>
<p class="lead">A counter keeps its own number while you are here.</p>
<div class="widget" data-counter><button type="button">Clicked <b>0</b> times</button></div>
</main>
<section id="log" aria-label="What pagefold.mount did">
<header>
<label class="switch"><input type="checkbox" id="cleanup" checked> Clean up when leaving</label>
<span class="stats">alive <b id="alive">0</b> · timers <b id="timers">0</b></span>
<button type="button" id="reset">Start over</button>
</header>
<ol id="lines"></ol>
</section>
<script src="../_lib/pagefold.js"></script>
<script src="main.js"></script>
</body>
</html> style.css
*{box-sizing:border-box}
html,body{min-height:100%}
body{margin:0;font:16px/1.6 system-ui,sans-serif;background:#0a0a0a;color:#fafaf8}
/* Links: the accent color with a quiet underline that gets stronger on hover */
a{color:#5af0ae;text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:.25em;text-decoration-color:rgba(90,240,174,.5);transition:color .15s,text-decoration-color .15s}
a:hover{color:#00ff9c;text-decoration-color:currentColor}
a:focus-visible{outline:2px solid #5af0ae;outline-offset:3px;border-radius:2px}
.top{display:flex;align-items:center;justify-content:space-between;gap:1rem;padding:.8rem 1.5rem;border-bottom:1px solid #2c2c2c}
.top strong{font:700 .95rem ui-monospace,Menlo,monospace}
.top nav{display:flex;gap:1.25rem;flex-wrap:wrap}
main{padding:2rem 1.5rem 3rem;max-width:46rem}
h1{margin:0 0 .5rem;font-size:2.1rem;line-height:1.15}
p{margin:0 0 1rem;max-width:36rem;color:#c9c9c4}
.lead{color:#a3a39e;font-size:1.05rem}
.cards{display:grid;grid-template-columns:repeat(3,1fr);gap:.75rem;margin-top:1.5rem}
.card{border:1px solid #2c2c2c;background:#141414;border-radius:12px;padding:.9rem}
.card i{display:block;height:3.2rem;border-radius:8px;margin-bottom:.6rem}
.card b{display:block;font-size:.95rem}
.card span{font-size:.82rem;color:#a3a39e}
@media (max-width:34rem){.cards{grid-template-columns:1fr}main{padding:1.25rem}}
.widget{margin:1.25rem 0;padding:1.1rem;border:1px solid #2c2c2c;border-radius:12px;background:#141414}
.widget button{font:inherit;color:#fafaf8;background:#1a1a1a;border:1px solid #2c2c2c;border-radius:8px;padding:.45rem .9rem;cursor:pointer}
.widget button:hover{border-color:#00ff9c}
.widget b{color:#00ff9c;font-variant-numeric:tabular-nums}
.time{font:700 2rem ui-monospace,Menlo,monospace;font-variant-numeric:tabular-nums}
.tablist{display:flex;gap:.4rem;margin-bottom:.75rem}
.tablist [aria-selected=true]{border-color:#00ff9c;background:#10261d}
.panel{margin:0}
main{padding-bottom:1rem}
#log{margin:0 1.5rem 1.5rem;border:1px solid #2c2c2c;border-radius:12px;background:#101010;max-width:46rem;font:.8rem ui-monospace,Menlo,monospace}
#log header{display:flex;flex-wrap:wrap;align-items:center;gap:.6rem 1.25rem;padding:.5rem .8rem;border-bottom:1px solid #2c2c2c;color:#a3a39e}
#log .switch{display:flex;align-items:center;gap:.45rem;color:#fafaf8;cursor:pointer}
#log .switch input{accent-color:#00ff9c;width:1rem;height:1rem;cursor:pointer}
#log .stats{margin-left:auto}
#log .stats b{color:#00ff9c;font-variant-numeric:tabular-nums}
#log.is-leaking .stats b{color:#ff9f5c}
#log button{font:inherit;color:#fafaf8;background:#1a1a1a;border:1px solid #2c2c2c;border-radius:8px;padding:.2rem .6rem;cursor:pointer}
#log button:hover{border-color:#00ff9c}
#lines{list-style:none;margin:0;padding:.4rem .8rem;max-height:9rem;overflow:auto}
#lines li{padding:.12rem 0;color:#a3a39e}
#lines .up{color:#00ff9c}
#lines .down{color:#7cc4ff}
#lines .leak{color:#ff9f5c} What to try
With the switch on (the default):
- On Counter, click the button a few times. Go to Clock: the log says the counter was cleaned.
- On Clock,
timersis 1. Go to another page and it is 0: the timer was stopped. - Go back and forth.
aliveis always the number of components that are on the page.
Now turn “Clean up when leaving” off and do the same:
- Go to Clock and away, three times.
timersgoes to 3. Each visit left one more timer running, on an element that is no longer on the page. - Go to Tabs and away a few times, then press any key. The log shows the same key handled by several tabs:
tabs #2,tabs #3,tabs #4. aliveonly goes up. The log saysNOT cleanedat every step.- Press Start over to begin again.
Why not cleaning up is bad
The switch does exactly what forgetting the cleanup does: the component is set up again on every visit, and the old one is never undone.
- Timers and animation loops keep running. Each one wakes the browser, burns CPU and battery, and works on elements that nobody can see. They add up with every visit.
- Listeners on
document,windowor a timer fire several times. The key press above is handled once per visit you made. Real examples are a tracking event sent three times, a modal that opens twice or a request that is made once per old page. - Memory grows. A listener or a timer holds on to its element, its data and everything its closure can reach, so the old page cannot be freed. On a site where visitors browse for an hour, this is a slow leak that nobody notices until it hurts.
- The bugs are hard to find. Everything works the first time and gets worse with use, so it is easy to miss while developing, where you rarely visit more than a few pages.
The cure is the one in the code: return a function that undoes what setup did, and pagefold.mount runs it when the element leaves. Anything you start (a timer, an observer, a listener outside the element, a third-party instance) needs an undo.
How it works
pagefold.mount('[data-clock]', (el) => {
const timer = setInterval(tick, 1000)
return () => clearInterval(timer)
})
- Each element is set up once, and cleaned up once, however many visits come and go.
- Return the cleanup. Without it, every visit to the clock would leave one more timer running.
- The code is loaded once, in the first page.
mounttakes care of setting up and cleaning up, so no script has to run again.
More in Initializing components and Gia.