Skip to content

← all comparisons

Timers + cleanup

Slide banner

A setInterval that has to be torn down on unmount. The contrast is in how each framework expresses "do this on mount, undo it on unmount".

Each panel header shows the source size of that component file (raw, gzipped, line count). Source-only — framework runtime (React ~45 KB, Vue ~35 KB, Svelte ~5 KB gzipped) is shared across every island and isn't included in these numbers.

React 692 B gzip · 39 lines
SlideBannerReact.tsx
How this works

useEffect with an empty dependency array runs once after mount. The captured interval id lives in the closure; the returned function is the cleanup, called on unmount. Setup and teardown sit together — easy to read, easy to forget.

import { useEffect, useState } from 'react';
import { promos, SLIDE_INTERVAL_MS } from '../data/promos';

export default function SlideBannerReact() {
  const [i, setI] = useState(0);

  useEffect(() => {
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    const id = setInterval(() => {
      setI((prev) => (prev + 1) % promos.length);
    }, SLIDE_INTERVAL_MS);
    return () => clearInterval(id);
  }, []);

  const slide = promos[i];

  return (
    <div className="relative h-48 overflow-hidden rounded-lg bg-slate-200 dark:bg-slate-700">
      <img src={slide.image} alt="" className="absolute inset-0 h-full w-full object-cover" />
      <div className="absolute inset-0 bg-black/40" />
      <div className="relative flex h-full flex-col justify-end gap-1 p-4 text-white">
        <h3 className="text-lg font-semibold">{slide.title}</h3>
        <p className="text-sm opacity-90">{slide.subtitle}</p>
      </div>
      <div className="absolute bottom-2 right-2 flex gap-1">
        {promos.map((p, idx) => (
          <button
            key={p.id}
            type="button"
            onClick={() => setI(idx)}
            aria-label={`Go to slide ${idx + 1}`}
            className={`h-2 w-2 rounded-full transition ${idx === i ? 'bg-white' : 'bg-white/40'}`}
          />
        ))}
      </div>
    </div>
  );
}
Vue 711 B gzip · 43 lines
SlideBannerVue.vue
How this works

Vue splits mount and unmount into two hooks. onMounted starts the interval; onUnmounted clears it. The timer id is held in a let outside both because they don't share a closure. More verbose than React/Svelte, but the symmetry is explicit.

<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import { promos, SLIDE_INTERVAL_MS } from '../data/promos';

const i = ref(0);
let timerId: ReturnType<typeof setInterval> | undefined;

onMounted(() => {
  if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
  timerId = setInterval(() => {
    i.value = (i.value + 1) % promos.length;
  }, SLIDE_INTERVAL_MS);
});

onUnmounted(() => {
  if (timerId) clearInterval(timerId);
});
</script>

<template>
  <div class="relative h-48 overflow-hidden rounded-lg bg-slate-200 dark:bg-slate-700">
    <img :src="promos[i].image" alt="" class="absolute inset-0 h-full w-full object-cover" />
    <div class="absolute inset-0 bg-black/40" />
    <div class="relative flex h-full flex-col justify-end gap-1 p-4 text-white">
      <h3 class="text-lg font-semibold">{{ promos[i].title }}</h3>
      <p class="text-sm opacity-90">{{ promos[i].subtitle }}</p>
    </div>
    <div class="absolute bottom-2 right-2 flex gap-1">
      <button
        v-for="(p, idx) in promos"
        :key="p.id"
        type="button"
        @click="i = idx"
        :aria-label="`Go to slide ${idx + 1}`"
        :class="[
          'h-2 w-2 rounded-full transition',
          idx === i ? 'bg-white' : 'bg-white/40',
        ]"
      />
    </div>
  </div>
</template>
Svelte 635 B gzip · 33 lines
SlideBannerSvelte.svelte
How this works

$effect is the runes-mode replacement for onMount. Its returned function is the cleanup — same shape as React's useEffect, no dependency array because the runes runtime auto-tracks reactive reads (there are none here, so it runs once).

<script lang="ts">
  import { promos, SLIDE_INTERVAL_MS } from '../data/promos';

  let i = $state(0);

  $effect(() => {
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    const id = setInterval(() => {
      i = (i + 1) % promos.length;
    }, SLIDE_INTERVAL_MS);
    return () => clearInterval(id);
  });
</script>

<div class="relative h-48 overflow-hidden rounded-lg bg-slate-200 dark:bg-slate-700">
  <img src={promos[i].image} alt="" class="absolute inset-0 h-full w-full object-cover" />
  <div class="absolute inset-0 bg-black/40"></div>
  <div class="relative flex h-full flex-col justify-end gap-1 p-4 text-white">
    <h3 class="text-lg font-semibold">{promos[i].title}</h3>
    <p class="text-sm opacity-90">{promos[i].subtitle}</p>
  </div>
  <div class="absolute bottom-2 right-2 flex gap-1">
    {#each promos as p, idx (p.id)}
      <button
        type="button"
        onclick={() => (i = idx)}
        aria-label={`Go to slide ${idx + 1}`}
        class={`h-2 w-2 rounded-full transition ${idx === i ? 'bg-white' : 'bg-white/40'}`}
      ></button>
    {/each}
  </div>
</div>