Astro 16 min de lecture

Coordonner plusieurs îlots Astro avec un store global EventTarget (sans Redux ni Context)

#astro#broadcastchannel#customevent#eventtarget#islands#localstorage#performance#preact#state management

Apprenez à relier plusieurs îlots Astro avec un store global EventTarget: état partagé, persistance et synchro inter-onglets, sans Redux ni Context.

Coordonner plusieurs îlots Astro avec un store global EventTarget (sans Redux ni Context)

Cet article montre pas à pas comment faire collaborer plusieurs îlots Astro autour d’un état partagé sans Redux, sans Context, et sans framework côté client. On s’appuie sur un store global ultra-léger basé sur EventTarget, avec persistance locale et synchronisation entre onglets. Le résultat est un pattern simple, typé, performant et facile à auditer.

Objectif

Vous allez apprendre à faire communiquer des îlots Astro isolés via un store global EventTarget. L’objectif est de centraliser l’état client tout en gardant des îlots indépendants au niveau du bundling et de l’hydratation. Vous verrez aussi comment ajouter une persistance dans localStorage et une synchronisation inter-onglets avec BroadcastChannel pour que vos filtres et votre panier suivent l’utilisateur même s’il ouvre plusieurs fenêtres.

Objectif et périmètre

Nous allons construire trois îlots: des filtres, une liste de produits et un badge de panier. Ces îlots partagent un état global minimal comprenant les filtres (recherche, catégories, intervalle de prix) et le panier (liste d’articles, quantités). Nous évitons Redux et Context en faveur d’un EventTarget dont l’API est très simple: getState, setState, on/off/emit. C’est adapté aux pages marketing et e-commerce légères où chaque îlot est bundle-hydraté isolément. En revanche, l’état n’est pas SSR par défaut: on injectera éventuellement un état initial minimal côté client (par exemple depuis l’URL). Pas de time-travel non plus, l’accent est mis sur la simplicité et un coût runtime quasi nul.

Bootstraper Astro + Preact

Commencez par créer un projet Astro minimal en TypeScript, puis ajoutez l’intégration Preact pour bénéficier de composants client sobres et rapides.

  • Création du projet:
npm create astro@latest my-islands
# Template: Minimal
# Typescript: Yes
cd my-islands
  • Installer Preact et intégrer dans Astro:
npm i @astrojs/preact preact

Éditez astro.config.mjs pour activer l’intégration et préparer un alias Vite que nous utiliserons pour garantir l’unicité du store:

// astro.config.mjs
import { defineConfig } from 'astro/config';
import preact from '@astrojs/preact';
import { fileURLToPath } from 'node:url';

export default defineConfig({
  integrations: [preact()],
  vite: {
    resolve: {
      alias: {
        '@': fileURLToPath(new URL('./src', import.meta.url)),
      },
    },
  },
});
  • Ajoutez une page src/pages/index.astro avec un squelette SSR et des emplacements pour les îlots. Nous la compléterons plus loin avec des données et le câblage.

Le store global: singleton basé sur EventTarget

Nous allons créer un singleton exporté à partir de src/lib/store.ts. L’instance est stockée sur window.__APP_STORE afin d’éviter les duplications dues au bundling isolé des îlots. Le store expose getState, setState(patch), on/off et gère des événements standardisés: state:filters, state:cart, state:any. Les mutations sont immuables par branches et batchées via queueMicrotask pour limiter les rafraîchissements.

// src/lib/store.ts
// Store global basé sur EventTarget avec persistance et sync inter-onglets.

export type Filters = {
  query: string;
  categories: string[];
  price: [number, number];
};

export type CartItem = { id: string; qty: number };
export type Cart = { items: CartItem[] };

export type StoreState = {
  filters: Filters;
  cart: Cart;
};

type Patch = Partial<{
  filters: Partial<Filters>;
  cart: Partial<Cart>;
}>;

type Listener = (ev: CustomEvent<any>) => void;

type SetStateOptions = {
  // Quand update distant (BroadcastChannel), on évite de reboucler les messages.
  __fromRemote?: boolean;
};

const STORAGE_KEY = 'app:state';
const BC_NAME = 'app-store';

function shallowEqual(a: any, b: any) {
  if (a === b) return true;
  if (!a || !b) return false;
  const ka = Object.keys(a);
  const kb = Object.keys(b);
  if (ka.length !== kb.length) return false;
  for (const k of ka) {
    if (a[k] !== b[k]) return false;
  }
  return true;
}

function safeParse<T>(str: string | null, fallback: T): T {
  if (!str) return fallback;
  try {
    return JSON.parse(str) as T;
  } catch {
    return fallback;
  }
}

function debounce<T extends (...args: any[]) => void>(fn: T, ms: number) {
  let t: any;
  return (...args: Parameters<T>) => {
    clearTimeout(t);
    t = setTimeout(() => fn(...args), ms);
  };
}

class AppStore extends EventTarget {
  private state: StoreState;
  private pending: Patch | null = null;
  private scheduled = false;
  private instanceId = (typeof crypto !== 'undefined' && 'randomUUID' in crypto)
    ? crypto.randomUUID()
    : String(Math.random()).slice(2);
  private bc: BroadcastChannel | null = null;
  private persistDebounced: () => void;

  constructor() {
    super();
    const initial: StoreState = {
      filters: { query: '', categories: [], price: [0, 200] },
      cart: { items: [] },
    };

    // Restaurer depuis localStorage si présent
    const restored = safeParse<StoreState>(
      typeof window !== 'undefined' ? window.localStorage.getItem(STORAGE_KEY) : null,
      initial
    );

    this.state = {
      ...initial,
      ...restored,
      filters: { ...initial.filters, ...(restored?.filters ?? {}) },
      cart: { ...initial.cart, ...(restored?.cart ?? {}) },
    };

    this.persistDebounced = debounce(() => {
      try {
        if (typeof window !== 'undefined') {
          window.localStorage.setItem(STORAGE_KEY, JSON.stringify(this.state));
        }
      } catch {
        // Ignorer erreurs de quota/privé
      }
    }, 150);

    // Synchronisation inter-onglets si disponible
    if (typeof BroadcastChannel !== 'undefined') {
      this.bc = new BroadcastChannel(BC_NAME);
      this.bc.onmessage = (ev) => {
        const msg = ev.data as { instanceId: string; patch: Patch };
        if (!msg || msg.instanceId === this.instanceId) return;
        this.setState(msg.patch, { __fromRemote: true });
      };
    }
  }

  getState(): StoreState {
    return this.state;
  }

  on(event: 'state:any' | 'state:filters' | 'state:cart', fn: Listener) {
    this.addEventListener(event, fn as EventListener);
  }

  off(event: 'state:any' | 'state:filters' | 'state:cart', fn: Listener) {
    this.removeEventListener(event, fn as EventListener);
  }

  emit<T>(event: string, detail: T) {
    this.dispatchEvent(new CustomEvent(event, { detail }));
  }

  setState(patch: Patch, opts: SetStateOptions = {}) {
    // Accumule les patchs et batch dans un microtask
    this.pending = {
      ...(this.pending ?? {}),
      ...patch,
      // Fusionner propriétés internes (ex: filters.query) si plusieurs setState consécutifs
      filters: { ...(this.pending?.filters ?? {}), ...(patch.filters ?? {}) },
      cart: { ...(this.pending?.cart ?? {}), ...(patch.cart ?? {}) },
    };

    if (!this.scheduled) {
      this.scheduled = true;
      queueMicrotask(() => {
        const pending = this.pending!;
        this.pending = null;
        this.scheduled = false;

        const prev = this.state;

        const next: StoreState = {
          ...prev,
          filters: pending.filters
            ? { ...prev.filters, ...pending.filters }
            : prev.filters,
          cart: pending.cart
            ? { ...prev.cart, ...pending.cart }
            : prev.cart,
        };

        const filtersChanged = !shallowEqual(prev.filters, next.filters);
        const cartChanged = !shallowEqual(prev.cart, next.cart);

        this.state = next;

        if (filtersChanged) this.emit('state:filters', next.filters);
        if (cartChanged) this.emit('state:cart', next.cart);
        if (filtersChanged || cartChanged) this.emit('state:any', next);

        // Persistance locale (debounce)
        this.persistDebounced();

        // Broadcast (uniquement les patches) si ce n'est pas un update distant
        if (!opts.__fromRemote && this.bc) {
          try {
            this.bc.postMessage({ instanceId: this.instanceId, patch: pending });
          } catch {
            // Ignorer si canal fermé
          }
        }
      });
    }
  }

  get id() {
    return this.instanceId;
  }
}

// Singleton global — garantit une seule instance même si l’ESM est dupliqué
declare global {
  interface Window {
    __APP_STORE?: AppStore;
  }
}

const globalAny = typeof window !== 'undefined' ? (window as any) : (globalThis as any);

const store: AppStore = (() => {
  if (globalAny.__APP_STORE) return globalAny.__APP_STORE as AppStore;
  const s = new AppStore();
  if (typeof window !== 'undefined') {
    globalAny.__APP_STORE = s;
  }
  return s;
})();

export default store;

Ce fichier définit un store léger, typé, avec persistance locale, synchronisation inter-onglets et événements granulaires. Notez le marquage instanceId pour éviter les boucles lors de la diffusion entre onglets.

Persistance locale et sync inter-onglets (optionnel)

Dans l’implémentation ci-dessus, la persistance est automatique: chaque mutation est sauvegardée dans localStorage avec un debounce de 150 ms pour ne pas saturer le stockage lors de séries d’updates. Lors de l’initialisation, le store restaure l’état s’il est présent.

La synchronisation inter-onglets utilise BroadcastChannel('app-store'). À chaque flush de mutations, le store envoie uniquement le patch (par exemple {filters: {query: 'hoodie'}}) taggé avec instanceId. Les autres onglets appliquent ce patch via setState({__fromRemote: true}) afin de ne pas reboucler la diffusion. Ce pattern réduit significativement le trafic et maintient la cohérence en temps réel entre plusieurs fenêtres.

Îlot 1: FilterControls.tsx (filtres interactifs)

Cet îlot rend une UI de filtres avec un champ de recherche, des cases à cocher de catégories et un slider d’intervalle de prix. À l’hydratation, il lit l’état initial depuis le store, se met à jour si des filtres changent ailleurs, et pousse des patchs granulaire côté store sans toucher le panier.

// src/components/FilterControls.tsx
import { useEffect, useMemo, useState } from 'preact/hooks';
import store, { Filters } from '@/lib/store';

const ALL_CATEGORIES = ['tshirt', 'hoodie', 'accessoires'];

export default function FilterControls() {
  const initial = store.getState().filters;

  const [query, setQuery] = useState<string>(initial.query);
  const [categories, setCategories] = useState<string[]>(initial.categories);
  const [price, setPrice] = useState<[number, number]>(initial.price);

  // Refléter les updates externes
  useEffect(() => {
    const onFilters = (e: CustomEvent<Filters>) => {
      const f = e.detail;
      setQuery(f.query);
      setCategories(f.categories);
      setPrice(f.price);
    };
    store.on('state:filters', onFilters);
    return () => store.off('state:filters', onFilters);
  }, []);

  const catsSet = useMemo(() => new Set(categories), [categories]);

  function toggleCat(cat: string) {
    const next = new Set(catsSet);
    if (next.has(cat)) next.delete(cat);
    else next.add(cat);
    const arr = Array.from(next);
    setCategories(arr);
    store.setState({ filters: { categories: arr } });
  }

  function onQueryChange(v: string) {
    setQuery(v);
    store.setState({ filters: { query: v } });
  }

  function onPriceMinChange(v: number) {
    const next: [number, number] = [v, price[1]];
    setPrice(next);
    store.setState({ filters: { price: next } });
  }

  function onPriceMaxChange(v: number) {
    const next: [number, number] = [price[0], v];
    setPrice(next);
    store.setState({ filters: { price: next } });
  }

  return (
    <section aria-labelledby="filters-title" style={{ padding: '0.5rem 0' }}>
      <h2 id="filters-title" style={{ margin: 0, fontSize: '1.1rem' }}>Filtres</h2>

      <div style={{ marginTop: '0.5rem' }}>
        <label htmlFor="q">Recherche</label>
        <input
          id="q"
          type="search"
          value={query}
          onInput={(e) => onQueryChange((e.currentTarget as HTMLInputElement).value)}
          placeholder="Rechercher un produit"
          style={{ display: 'block', width: '100%', maxWidth: 420 }}
        />
      </div>

      <fieldset style={{ marginTop: '0.75rem' }}>
        <legend>Catégories</legend>
        <div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
          {ALL_CATEGORIES.map((cat) => (
            <label key={cat}>
              <input
                type="checkbox"
                checked={catsSet.has(cat)}
                onChange={() => toggleCat(cat)}
              />
              {' '}
              {cat}
            </label>
          ))}
        </div>
      </fieldset>

      <fieldset style={{ marginTop: '0.75rem' }}>
        <legend>Prix</legend>
        <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
          <label>
            Min
            <input
              type="number"
              min={0}
              value={price[0]}
              onInput={(e) => onPriceMinChange(Number((e.currentTarget as HTMLInputElement).value))}
              style={{ width: 90, marginLeft: 6 }}
            />
          </label>
          <span>—</span>
          <label>
            Max
            <input
              type="number"
              min={0}
              value={price[1]}
              onInput={(e) => onPriceMaxChange(Number((e.currentTarget as HTMLInputElement).value))}
              style={{ width: 90, marginLeft: 6 }}
            />
          </label>
        </div>
      </fieldset>
    </section>
  );
}

Utilisez l’hydratation client:visible depuis la page pour éviter de charger l’UI hors viewport. Le composant fonctionne également avec des valeurs initiales par défaut pour un fallback SSR trivial.

Îlot 2: ProductList.tsx (filtrage réactif côté client)

La liste de produits est rendue avec toutes les données en SSR, puis filtrée côté client selon les filtres globaux. On se souscrit à state:filters et on debouncera la recomputation si le dataset est volumineux, tout en évitant les sauts de layout via un petit skeleton et contain: content.

// src/components/ProductList.tsx
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import store, { Filters } from '@/lib/store';

export type Product = {
  id: string;
  title: string;
  category: string;
  price: number;
  image?: string;
};

type Props = {
  allProducts: Product[];
};

function debounce<T extends (...args: any[]) => void>(fn: T, ms: number) {
  let t: any;
  return (...args: Parameters<T>) => {
    clearTimeout(t);
    t = setTimeout(() => fn(...args), ms);
  };
}

export default function ProductList({ allProducts }: Props) {
  const [visible, setVisible] = useState<Product[]>([]);
  const [hydrated, setHydrated] = useState(false);

  const filtersRef = useRef<Filters>(store.getState().filters);

  // Calcul de filtrage
  const applyFilters = (f: Filters) => {
    const q = f.query.trim().toLowerCase();
    const cats = new Set(f.categories);
    const [min, max] = f.price;

    const result = allProducts.filter((p) => {
      if (q && !p.title.toLowerCase().includes(q)) return false;
      if (cats.size && !cats.has(p.category)) return false;
      if (p.price < min || p.price > max) return false;
      return true;
    });
    setVisible(result);
  };

  const applyFiltersDebounced = useMemo(() => debounce(applyFilters, 100), []);

  useEffect(() => {
    // Hydratation: initialisation
    applyFilters(filtersRef.current);
    setHydrated(true);

    const onFilters = (e: CustomEvent<Filters>) => {
      filtersRef.current = e.detail;
      applyFiltersDebounced(e.detail);
    };
    store.on('state:filters', onFilters);
    return () => store.off('state:filters', onFilters);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  function addToCart(productId: string) {
    const curr = store.getState().cart.items;
    const idx = curr.findIndex((it) => it.id === productId);
    let next = curr.slice();
    if (idx >= 0) {
      next[idx] = { ...next[idx], qty: next[idx].qty + 1 };
    } else {
      next.push({ id: productId, qty: 1 });
    }
    store.setState({ cart: { items: next } });
  }

  return (
    <section aria-labelledby="products-title" style={{ contain: 'content' }}>
      <h2 id="products-title" style={{ margin: '0 0 0.5rem' }}>Produits</h2>

      {!hydrated && (
        <div aria-hidden="true" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: '0.75rem' }}>
          {Array.from({ length: 8 }).map((_, i) => (
            <div key={i} style={{ background: '#f2f2f2', height: 160, borderRadius: 8 }} />
          ))}
        </div>
      )}

      {hydrated && (
        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: '1rem' }}>
          {visible.map((p) => (
            <article key={p.id} style={{ border: '1px solid #e5e5e5', borderRadius: 8, padding: '0.75rem' }}>
              <div style={{ fontWeight: 600 }}>{p.title}</div>
              <div style={{ color: '#666' }}>{p.category}</div>
              <div style={{ margin: '0.5rem 0' }}>{p.price.toFixed(2)} €</div>
              <button onClick={() => addToCart(p.id)}>Ajouter au panier</button>
            </article>
          ))}
          {visible.length === 0 && (
            <p>Aucun produit ne correspond aux filtres actuels.</p>
          )}
        </div>
      )}
    </section>
  );
}

Ce composant consomme allProducts côté SSR, puis applique des filtres côté client. Le debounce de 100 ms amortit les rafales de frappes ou de glissements de slider. Le skeleton s’affiche avant la fin de l’hydratation, et la grille utilise contain: content pour limiter l’impact du reflow.

Îlot 3: CartBadge.tsx (compteur panier global)

Le badge de panier lit le total de quantités au montage et se met à jour en temps réel grâce à l’événement state:cart. Il est hydraté très tôt pour rester cohérent visuellement.

// src/components/CartBadge.tsx
import { useEffect, useState } from 'preact/hooks';
import store, { Cart } from '@/lib/store';

function totalQty(cart: Cart) {
  return cart.items.reduce((acc, it) => acc + it.qty, 0);
}

export default function CartBadge() {
  const [qty, setQty] = useState<number>(totalQty(store.getState().cart));

  useEffect(() => {
    const onCart = (e: CustomEvent<Cart>) => {
      setQty(totalQty(e.detail));
    };
    store.on('state:cart', onCart);
    return () => store.off('state:cart', onCart);
  }, []);

  return (
    <div aria-live="polite" aria-atomic="true" style={{ position: 'relative', display: 'inline-flex', alignItems: 'center' }}>
      <span role="img" aria-label="Panier" style={{ fontSize: '1.25rem' }}>🛒</span>
      <span
        style={{
          marginLeft: 6,
          background: '#111',
          color: 'white',
          borderRadius: 999,
          fontSize: '0.8rem',
          minWidth: 22,
          textAlign: 'center',
          padding: '2px 6px',
        }}
      >
        {qty}
      </span>
    </div>
  );
}

L’attribut aria-live="polite" annonce les mises à jour aux technologies d’assistance, améliorant l’accessibilité pour les utilisateurs utilisant un lecteur d’écran.

Câblage de la page index.astro

La page SSR prépare les données (liste de produits), monte les trois îlots et peut injecter un état initial client (par exemple lecture des filtres dans les paramètres d’URL). Voici un exemple complet minimal.

---
// src/pages/index.astro
import FilterControls from '@/components/FilterControls.tsx';
import ProductList from '@/components/ProductList.tsx';
import CartBadge from '@/components/CartBadge.tsx';

// Exemple minimal de données SSR.
// En production, lisez depuis des fichiers JSON, une API, ou Content Collections.
const allProducts = [
  { id: 'p1', title: 'T-Shirt Astro', category: 'tshirt', price: 25 },
  { id: 'p2', title: 'Hoodie Nebula', category: 'hoodie', price: 59 },
  { id: 'p3', title: 'Casquette Comet', category: 'accessoires', price: 19 },
  { id: 'p4', title: 'T-Shirt Comet', category: 'tshirt', price: 29 },
  { id: 'p5', title: 'Hoodie Galaxy', category: 'hoodie', price: 69 },
];
---

<html lang="fr">
  <head>
    <meta charset="utf-8" />
    <title>Islands + EventTarget Store</title>
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <style>
      :root { color-scheme: light dark; }
      body { font-family: system-ui, sans-serif; margin: 2rem auto; max-width: 960px; padding: 0 1rem; }
      header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; }
    </style>
  </head>
  <body>
    <header>
      <h1 style="margin: 0; font-size: 1.25rem;">Boutique démo</h1>
      <CartBadge client:load />
    </header>

    <FilterControls client:visible />
    <ProductList allProducts={allProducts} client:visible />

    <!-- Injecter un état initial minimal depuis l'URL si présent -->
    <script is:inline>
      (function () {
        try {
          const params = new URLSearchParams(location.search);
          const q = params.get('q');
          const cats = params.getAll('cat'); // ?cat=tshirt&cat=hoodie
          const min = params.get('min');
          const max = params.get('max');

          // Attendre que le store global soit disponible (il vit sur window.__APP_STORE)
          function applyInitial() {
            const s = window.__APP_STORE;
            if (!s) return false;
            const patch = { filters: {} };
            if (q !== null) patch.filters.query = q;
            if (cats.length) patch.filters.categories = cats;
            if (min !== null || max !== null) {
              const curr = s.getState().filters.price;
              const vmin = min !== null ? Number(min) : curr[0];
              const vmax = max !== null ? Number(max) : curr[1];
              patch.filters.price = [vmin, vmax];
            }
            if (Object.keys(patch.filters).length) {
              s.setState(patch);
            }
            return true;
          }

          if (!applyInitial()) {
            document.addEventListener('astro:page-load', applyInitial, { once: true });
          }
        } catch {}
      })();
    </script>
  </body>
</html>

Dans cet exemple, les filtres peuvent être préremplis via des paramètres d’URL comme ?q=hoodie&cat=hoodie&min=20&max=80. L’injection se contente de publier un patch sur le store global dès qu’il est disponible.

Garantir l’unicité du store dans le bundle

Pour garder une seule instance du store même quand les îlots sont bundle-hydratés indépendamment, importez-le via un alias unique et utilisez un fallback global. L’alias Vite '@' configuré dans astro.config.mjs vous permet d’importer via '@/lib/store' partout. À l’exécution, le store se range dans window.__APP_STORE et réutilise l’instance existante s’il la trouve.

Vérifiez l’unicité à l’exécution depuis la console du navigateur. Vous devriez obtenir le même instanceId dans tous les îlots:

// Console
window.__APP_STORE.id
// => "b3c9a6b8-..." (identique depuis chaque îlot)

Si vous constatez des IDs différents, vérifiez que tous vos imports pointent bien vers '@/lib/store' et qu’aucun chemin relatif ne double le module.

Performance, nettoyage, accessibilité

Pour éviter les re-renders inutiles, les setState sont batchés dans un queueMicrotask; plusieurs mutations consécutives sont ainsi fusionnées avant diffusion. Côté composants, pensez à nettoyer les abonnements dans le return de useEffect afin d’éviter des fuites quand l’îlot est démonté. Limitez la taille de l’état partagé au strict nécessaire et laissez les datasets volumineux en SSR/props, comme la liste complète des produits. Côté accessibilité, liez systématiquement vos contrôles à des labels, fournissez des legends pour vos fieldsets, et annoncez les changements importants (comme le compteur du panier) via aria-live="polite" pour informer les utilisateurs de lecteurs d’écran.

Debug et tests manuels rapides

Pour inspecter et tester rapidement, ouvrez la console:

// Lire l’état
window.__APP_STORE.getState()

// Forcer un filtre
window.__APP_STORE.setState({ filters: { query: 't-shirt' } })

// Ajouter un article au panier
const s = window.__APP_STORE;
const curr = s.getState().cart.items;
s.setState({ cart: { items: [...curr, { id: 'p1', qty: 1 }] } })

Testez la synchronisation inter-onglets en ouvrant deux onglets de la même page, en modifiant un filtre dans l’un et en vérifiant que l’autre réagit instantanément. Vérifiez la persistance en rechargeant la page: les filtres et le panier doivent être restaurés depuis localStorage.

Checklist

Avant de publier, relisez l’article et votre code pour vous assurer que l’API du store, les noms d’événements et les chemins d’import sont cohérents. Exécutez l’ensemble des snippets: la création du projet, la configuration de l’intégration Preact et l’alias Vite, puis le démarrage du serveur de dev. Vérifiez ensuite le comportement des îlots, la persistance, la synchronisation inter-onglets et la non-duplication du store via l’instanceId. Une fois ces tests validés, vous pouvez pousser votre code et déployer en toute confiance.

Conclusion

Avec un simple EventTarget, vous pouvez partager un état global entre plusieurs îlots Astro sans alourdir votre stack. Ce pattern évite le prop-drilling, s’intègre naturellement aux îlots hydratés, et reste facile à raisonner grâce à une API minuscule. En ajoutant la persistance locale et la synchronisation inter-onglets, vous offrez une expérience robuste et cohérente, tout en gardant des bundles clients légers.

Ressources