Creare uno slideshow di immagini con React
Nei primi tre articoli di questa serie abbiamo costruito lo stesso slideshow con il CSS, con JavaScript puro e con Vue.js. In questo quarto articolo affrontiamo il problema con React, che adotta un modello diverso da quello di Vue: non esiste un sistema di reattività che intercetta le modifiche alle variabili, ma componenti che sono funzioni pure dello stato, aggiornamenti espliciti tramite setState ed effetti collaterali dichiarati con useEffect. Costruiremo un custom hook useSlideshow basato su un reducer, un componente ImageSlideshow e un piccolo hook di utilità per la riproduzione automatica, prestando attenzione alle insidie tipiche di React: closure obsolete, effetti che si riattivano troppo spesso e pulizia delle risorse.
Creazione del progetto
Creiamo un progetto React con Vite. Come per Vue, non servono librerie aggiuntive: tutto ciò che useremo fa parte di React e delle API del browser.
npm create vite@latest slideshow-react -- --template react
cd slideshow-react
npm install
npm run dev
La struttura del progetto ricalca quella dell'articolo precedente: la logica vive negli hook, la presentazione nel componente.
src/
hooks/
useSlideshow.js
useInterval.js
useMediaQuery.js
usePageVisibility.js
components/
ImageSlideshow.jsx
ImageSlideshow.css
App.jsx
Il modello mentale di React
In React un componente è una funzione che riceve le props e restituisce una descrizione dell'interfaccia. Ogni volta che lo stato cambia, React chiama di nuovo la funzione, confronta il risultato con quello precedente e applica al DOM solo le differenze. Due conseguenze sono importanti per il nostro slideshow.
La prima è che le variabili locali del componente vengono ricreate a ogni rendering: tutto ciò che deve sopravvivere tra un rendering e l'altro va conservato con gli hook, cioè useState o useReducer per i valori che influenzano l'interfaccia e useRef per quelli che non la influenzano. La seconda è che le funzioni definite nel componente "catturano" i valori del rendering in cui sono state create. Un timer avviato al primo rendering che legge l'indice corrente leggerà per sempre il valore di quel rendering, a meno che non prendiamo precauzioni. È il problema delle closure obsolete, e lo affronteremo esplicitamente.
Lo stato come reducer
Lo stato di navigazione dello slideshow comprende l'indice corrente e la direzione dell'ultimo spostamento, che useremo per le animazioni. I due valori cambiano sempre insieme e il nuovo stato dipende da quello precedente: è il caso ideale per useReducer. Un reducer è una funzione pura che riceve lo stato attuale e un'azione e restituisce il nuovo stato. Essendo pura, non dipende da nulla di esterno e può essere testata senza montare alcun componente.
// src/hooks/useSlideshow.js
import { useCallback, useEffect, useReducer } from 'react';
export const initialState = { index: 0, direction: 'next' };
function normalizeIndex(index, total, loop) {
if (loop) {
// Il doppio modulo gestisce correttamente anche gli indici negativi
return ((index % total) + total) % total;
}
return Math.min(Math.max(index, 0), total - 1);
}
export function slideshowReducer(state, action) {
const { total, loop } = action;
if (total === 0) {
return state;
}
switch (action.type) {
case 'goTo': {
const target = normalizeIndex(action.index, total, loop);
if (target === state.index) {
// Restituire lo stesso oggetto evita un nuovo rendering
return state;
}
return {
index: target,
// Si confronta l'indice richiesto, non quello normalizzato
direction: action.index > state.index ? 'next' : 'prev'
};
}
case 'next':
return slideshowReducer(state, { ...action, type: 'goTo', index: state.index + 1 });
case 'prev':
return slideshowReducer(state, { ...action, type: 'goTo', index: state.index - 1 });
case 'clamp':
// Se il numero di slide diminuisce, l'indice deve restare valido
return state.index > total - 1 ? { ...state, index: total - 1 } : state;
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
Le azioni next e prev sono definite in termini di goTo, così la normalizzazione e il calcolo della direzione vivono in un solo punto, esattamente come nel metodo goTo della versione JavaScript. Quando il risultato coincide con lo stato attuale, il reducer restituisce lo stesso oggetto: React confronta il vecchio e il nuovo stato con Object.is e, se sono identici, evita il rendering.
Il numero di slide e l'opzione di loop vengono passati con ogni azione invece di essere salvati nello stato. Sono dati che appartengono alle props, e duplicarli nello stato significherebbe doverli tenere sincronizzati, introducendo una possibile fonte di incoerenza.
Un vantaggio concreto del reducer è proprio quello legato alle closure. Il dispatch restituito da useReducer ha un'identità stabile per tutta la vita del componente, e le azioni next e prev calcolano il nuovo indice a partire dallo stato corrente ricevuto dal reducer, non da una variabile catturata. Un timer che chiama dispatch({ type: 'next' }) funzionerà sempre correttamente, indipendentemente da quando è stato creato.
Il hook useSlideshow
Il custom hook avvolge il reducer ed espone un'API comoda: lo stato e tre funzioni di navigazione che non richiedono a chi le usa di passare ogni volta il totale e l'opzione di loop.
export function useSlideshow({ total, loop = true }) {
const [state, dispatch] = useReducer(slideshowReducer, initialState);
const goTo = useCallback(
(index) => dispatch({ type: 'goTo', index, total, loop }),
[total, loop]
);
const next = useCallback(() => dispatch({ type: 'next', total, loop }), [total, loop]);
const prev = useCallback(() => dispatch({ type: 'prev', total, loop }), [total, loop]);
useEffect(() => {
dispatch({ type: 'clamp', total, loop });
}, [total, loop]);
return {
index: state.index,
direction: state.direction,
canGoPrev: loop || state.index > 0,
canGoNext: loop || state.index < total - 1,
goTo,
next,
prev
};
}
Le funzioni sono avvolte in useCallback perché le useremo come dipendenze di altri hook: senza memoizzazione, verrebbero ricreate a ogni rendering e farebbero riattivare inutilmente gli effetti che le utilizzano. I valori canGoPrev e canGoNext, invece, sono semplici espressioni calcolate a ogni rendering. In React non serve uno strumento dedicato per i valori derivati: basta calcolarli durante il rendering, e useMemo va riservato ai calcoli realmente costosi.
Il hook useInterval
Usare setInterval direttamente in un effetto è una delle trappole più note di React. Se la callback cambia a ogni rendering e la inseriamo tra le dipendenze, l'intervallo viene cancellato e ricreato continuamente; se non la inseriamo, l'intervallo continua a chiamare la versione del primo rendering. La soluzione consolidata è conservare la callback più recente in un ref e lasciare che l'intervallo chiami sempre quella.
// src/hooks/useInterval.js
import { useEffect, useRef } from 'react';
export function useInterval(callback, delay, resetKey) {
const savedCallback = useRef(callback);
// Il ref punta sempre alla callback dell'ultimo rendering
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
// Un delay null sospende l'intervallo
if (delay === null) {
return undefined;
}
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay, resetKey]);
}
L'effetto che crea l'intervallo dipende soltanto dal ritardo e da una chiave di reset, non dalla callback. Il valore null come ritardo sospende l'intervallo: è un modo dichiarativo di esprimere la pausa, perché il componente non deve chiamare funzioni di avvio e arresto, ma semplicemente calcolare se in questo momento il timer debba essere attivo o no.
Il parametro resetKey serve a far ripartire il conteggio da zero. Passando l'indice corrente come chiave, qualunque cambio di slide, manuale o automatico, causa la pulizia dell'intervallo precedente e la creazione di uno nuovo. È lo stesso risultato che in Vue avevamo ottenuto con il watcher sull'indice.
Gli hook per le preferenze del browser
La riproduzione automatica deve fermarsi quando l'utente preferisce un movimento ridotto e quando la scheda del browser non è visibile. Entrambe le informazioni provengono da sorgenti esterne a React, che possono cambiare in qualunque momento. Per sottoscrivere sorgenti esterne React offre un hook dedicato, useSyncExternalStore, che gestisce correttamente anche il rendering concorrente e quello lato server.
// src/hooks/useMediaQuery.js
import { useCallback, useSyncExternalStore } from 'react';
export function useMediaQuery(query) {
const subscribe = useCallback((onChange) => {
const mediaQuery = window.matchMedia(query);
mediaQuery.addEventListener('change', onChange);
return () => mediaQuery.removeEventListener('change', onChange);
}, [query]);
const getSnapshot = () => window.matchMedia(query).matches;
// Durante il rendering lato server la media query non è disponibile
const getServerSnapshot = () => false;
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
// src/hooks/usePageVisibility.js
import { useSyncExternalStore } from 'react';
function subscribe(onChange) {
document.addEventListener('visibilitychange', onChange);
return () => document.removeEventListener('visibilitychange', onChange);
}
export function usePageVisibility() {
return useSyncExternalStore(
subscribe,
() => document.visibilityState === 'visible',
() => true
);
}
Il funzionamento è semplice: subscribe registra un listener e restituisce la funzione che lo rimuove, mentre getSnapshot legge il valore corrente. Quando l'evento scatta, React rilegge lo snapshot e, se è cambiato, esegue un nuovo rendering. La funzione subscribe di usePageVisibility è definita fuori dal componente, quindi ha un'identità stabile; quella di useMediaQuery dipende dalla query ed è memoizzata con useCallback, per evitare che React rinnovi la sottoscrizione a ogni rendering.
Il componente ImageSlideshow: lo stato
Possiamo ora assemblare il componente. La prima parte raccoglie lo stato e calcola se la riproduzione automatica debba essere attiva.
// src/components/ImageSlideshow.jsx
import { useEffect, useRef, useState } from 'react';
import { useSlideshow } from '../hooks/useSlideshow.js';
import { useInterval } from '../hooks/useInterval.js';
import { useMediaQuery } from '../hooks/useMediaQuery.js';
import { usePageVisibility } from '../hooks/usePageVisibility.js';
import './ImageSlideshow.css';
const SWIPE_THRESHOLD = 50;
export default function ImageSlideshow({
images,
autoplay = true,
interval = 5000,
loop = true,
label = 'Galleria di immagini',
onChange
}) {
const total = images.length;
const { index, direction, canGoPrev, canGoNext, goTo, next, prev } = useSlideshow({ total, loop });
// Pausa esplicita richiesta dall'utente
const [isPaused, setIsPaused] = useState(false);
// Pausa temporanea durante hover o focus
const [isInteracting, setIsInteracting] = useState(false);
const [statusMessage, setStatusMessage] = useState('');
const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
const isPageVisible = usePageVisibility();
const pointerStartX = useRef(null);
const previousIndex = useRef(index);
// La riproduzione automatica è un valore derivato, non uno stato
const isRunning = autoplay
&& !isPaused
&& !isInteracting
&& !prefersReducedMotion
&& isPageVisible
&& total > 1
&& canGoNext;
useInterval(next, isRunning ? interval : null, index);
// ... continua
}
La variabile isRunning è il corrispettivo della proprietà calcolata di Vue e del metodo canAutoplay della versione JavaScript. In React non ha bisogno di alcun meccanismo particolare: viene ricalcolata a ogni rendering e, poiché ogni cambiamento di uno dei suoi fattori provoca un rendering, è sempre aggiornata. Passandola a useInterval come ritardo oppure come null, la riproduzione si avvia e si ferma da sola.
I valori conservati con useRef non influenzano l'interfaccia: la coordinata iniziale dello swipe e l'indice precedente servono solo alla logica interna. Modificarli non deve provocare un rendering, ed è proprio questa la differenza tra un ref e uno stato.
Il componente ImageSlideshow: gli effetti
Due effetti completano la logica: il primo notifica il componente padre quando la slide cambia, il secondo precarica l'immagine successiva.
// Notifica il padre a ogni cambio di slide
useEffect(() => {
if (previousIndex.current !== index) {
onChange?.({ index, previousIndex: previousIndex.current });
previousIndex.current = index;
}
}, [index, onChange]);
// Precarica l'immagine successiva
useEffect(() => {
const nextImage = images[(index + 1) % total];
if (nextImage) {
const image = new Image();
image.src = nextImage.src;
}
}, [index, images, total]);
Nel primo effetto il ref previousIndex ha un doppio ruolo: fornisce al padre l'indice di provenienza e impedisce di emettere una notifica al montaggio del componente, quando l'indice non è ancora cambiato. Se il componente padre passasse come onChange una funzione creata a ogni rendering, l'effetto si riattiverebbe spesso, ma il controllo sull'indice eviterebbe comunque notifiche duplicate.
Il precaricamento ha la stessa motivazione vista nell'articolo su Vue: durante la transizione nel DOM esistono solo la slide uscente e quella entrante, quindi l'immagine successiva deve essere già in cache nel momento in cui serve.
Il componente ImageSlideshow: i gestori di eventi
I gestori di eventi traducono le interazioni dell'utente in chiamate alle funzioni di navigazione. Come nelle versioni precedenti, le azioni avviate dall'utente vengono annunciate agli screen reader, mentre i cambi automatici no.
function announce(targetIndex) {
setStatusMessage(`Immagine ${targetIndex + 1} di ${total}`);
}
function normalize(targetIndex) {
return loop
? ((targetIndex % total) + total) % total
: Math.min(Math.max(targetIndex, 0), total - 1);
}
function navigateTo(targetIndex) {
goTo(targetIndex);
announce(normalize(targetIndex));
}
function handleKeyDown(event) {
const actions = {
ArrowLeft: () => navigateTo(index - 1),
ArrowRight: () => navigateTo(index + 1),
Home: () => navigateTo(0),
End: () => navigateTo(total - 1)
};
const action = actions[event.key];
if (action) {
event.preventDefault();
action();
}
}
function handleBlur(event) {
// Riprendi solo se il focus esce davvero dal componente
if (!event.currentTarget.contains(event.relatedTarget)) {
setIsInteracting(false);
}
}
function handlePointerDown(event) {
if (event.isPrimary && event.button === 0) {
pointerStartX.current = event.clientX;
}
}
function handlePointerUp(event) {
if (pointerStartX.current === null) {
return;
}
const deltaX = event.clientX - pointerStartX.current;
pointerStartX.current = null;
if (Math.abs(deltaX) >= SWIPE_THRESHOLD) {
navigateTo(deltaX < 0 ? index + 1 : index - 1);
}
}
function resetPointer() {
pointerStartX.current = null;
}
A differenza di Vue, in React non possiamo leggere il nuovo indice subito dopo aver chiamato goTo: l'aggiornamento dello stato è asincrono e il valore di index nella funzione corrente resta quello del rendering in corso. Per questo navigateTo calcola l'indice di destinazione in anticipo, con la stessa normalizzazione del reducer, e lo usa per il messaggio. È un esempio concreto di come il modello di React richieda di ragionare in termini di "istantanee" dello stato.
In React gli eventi onFocus e onBlur si propagano verso l'alto, a differenza degli eventi nativi omonimi: si comportano come focusin e focusout. Possiamo quindi ascoltarli sul contenitore e usare relatedTarget per capire se il focus sta lasciando il componente, come già fatto nelle versioni precedenti.
Il componente ImageSlideshow: il JSX
Il JSX descrive l'interfaccia in funzione dello stato. Per le animazioni adottiamo una strategia diversa da quella di Vue: React non ha un equivalente integrato del componente <Transition>, che mantiene nel DOM l'elemento uscente fino al termine dell'animazione. Invece di aggiungere una libreria come React Transition Group, manteniamo tutte le slide nel DOM e animiamo con il CSS quella attiva, sfruttando un attributo data-direction per scegliere il verso dell'animazione.
if (total === 0) {
return null;
}
return (
<figure
className="slideshow"
aria-roledescription="carousel"
aria-label={label}
data-direction={direction}
onMouseEnter={() => setIsInteracting(true)}
onMouseLeave={() => setIsInteracting(false)}
onFocus={() => setIsInteracting(true)}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
>
<ul
className="slideshow-slides"
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerCancel={resetPointer}
>
{images.map((image, i) => {
const isActive = i === index;
return (
<li
key={image.src}
className={isActive ? 'slideshow-slide is-active' : 'slideshow-slide'}
aria-roledescription="slide"
aria-label={`${i + 1} di ${total}`}
aria-hidden={!isActive}
inert={!isActive}
>
<img
src={image.src}
alt={image.alt}
draggable={false}
loading={i === 0 ? 'eager' : 'lazy'}
/>
{image.caption && <p className="slideshow-caption">{image.caption}</p>}
</li>
);
})}
</ul>
<button
type="button"
className="slideshow-arrow slideshow-prev"
aria-label="Immagine precedente"
disabled={!canGoPrev}
onClick={() => navigateTo(index - 1)}
>
‹
</button>
<button
type="button"
className="slideshow-arrow slideshow-next"
aria-label="Immagine successiva"
disabled={!canGoNext}
onClick={() => navigateTo(index + 1)}
>
›
</button>
{autoplay && (
<button
type="button"
className="slideshow-toggle"
aria-pressed={isPaused}
onClick={() => setIsPaused((paused) => !paused)}
>
{isPaused ? 'Riprendi' : 'Pausa'}
</button>
)}
<nav className="slideshow-dots" aria-label="Seleziona immagine">
{images.map((image, i) => (
<button
key={image.src}
type="button"
className="slideshow-dot"
aria-label={`Vai all'immagine ${i + 1}`}
aria-current={i === index ? 'true' : undefined}
onClick={() => navigateTo(i)}
/>
))}
</nav>
<p className="slideshow-status visually-hidden" aria-live="polite">
{statusMessage}
</p>
</figure>
);
Le chiavi degli elementi delle liste sono gli URL delle immagini, non gli indici. La chiave serve a React per riconoscere lo stesso elemento tra un rendering e l'altro: se l'elenco delle immagini venisse riordinato o filtrato, chiavi basate sull'indice farebbero associare a React gli elementi sbagliati.
L'attributo inert è supportato come proprietà booleana a partire da React 19. Nelle versioni precedenti React non lo riconosceva e occorreva passare una stringa vuota per attivarlo e undefined per disattivarlo. Con React 19 il valore booleano viene gestito correttamente, aggiungendo o rimuovendo l'attributo.
Il pulsante di pausa aggiorna lo stato con una funzione, setIsPaused((paused) => !paused), invece di usare il valore corrente di isPaused. La forma funzionale garantisce di partire sempre dallo stato più recente, anche se più aggiornamenti venissero accodati nello stesso ciclo.
Gli stili
Il foglio di stile riprende il layout delle versioni precedenti. La parte nuova riguarda l'animazione: la slide che diventa attiva riceve un'animazione con @keyframes il cui verso dipende dall'attributo data-direction del contenitore.
/* src/components/ImageSlideshow.css */
.slideshow {
position: relative;
max-width: 960px;
margin: 0 auto;
overflow: hidden;
border-radius: 8px;
background: #111;
}
.slideshow-slides {
position: relative;
aspect-ratio: 16 / 9;
margin: 0;
padding: 0;
list-style: none;
touch-action: pan-y;
}
.slideshow-slide {
position: absolute;
inset: 0;
visibility: hidden;
}
.slideshow-slide.is-active {
visibility: visible;
z-index: 1;
animation: slide-in-next 0.5s ease both;
}
.slideshow[data-direction="prev"] .slideshow-slide.is-active {
animation-name: slide-in-prev;
}
@keyframes slide-in-next {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
@keyframes slide-in-prev {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
.slideshow-slide img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
user-select: none;
}
.slideshow-caption {
position: absolute;
left: 0;
right: 0;
bottom: 0;
margin: 0;
padding: 2.5rem 1.25rem 1rem;
color: #fff;
background: linear-gradient(transparent, rgb(0 0 0 / 0.7));
}
.slideshow-arrow {
position: absolute;
top: 50%;
z-index: 2;
translate: 0 -50%;
width: 3rem;
height: 3rem;
border: 0;
border-radius: 50%;
font-size: 2rem;
line-height: 1;
color: #fff;
background: rgb(0 0 0 / 0.45);
cursor: pointer;
}
.slideshow-arrow:disabled {
opacity: 0.3;
cursor: default;
}
.slideshow-prev {
left: 1rem;
}
.slideshow-next {
right: 1rem;
}
.slideshow-toggle {
position: absolute;
top: 1rem;
right: 1rem;
z-index: 2;
padding: 0.4rem 0.8rem;
border: 0;
border-radius: 4px;
color: #fff;
background: rgb(0 0 0 / 0.55);
cursor: pointer;
}
.slideshow-dots {
position: absolute;
top: 1rem;
left: 50%;
z-index: 2;
translate: -50% 0;
display: flex;
gap: 0.5rem;
}
.slideshow-dot {
width: 0.75rem;
height: 0.75rem;
padding: 0;
border: 2px solid #fff;
border-radius: 50%;
background: transparent;
cursor: pointer;
}
.slideshow-dot[aria-current="true"] {
background: #fff;
}
.slideshow button:focus-visible {
outline: 3px solid #4da3ff;
outline-offset: 3px;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.slideshow-slide.is-active {
animation: none;
}
}
Questa tecnica sfrutta un comportamento preciso del browser: un'animazione CSS parte nel momento in cui viene applicata a un elemento. Quando una slide riceve la classe is-active, la sua animazione di ingresso inizia; quando la perde, l'animazione viene rimossa. La slide entrante, con z-index superiore, scorre sopra quella uscente, che diventa subito invisibile. Il risultato è un effetto di scorrimento sovrapposto, leggermente diverso da quello di Vue, in cui le due slide si muovevano insieme, ma ottenuto senza alcuna dipendenza e senza dover ritardare la rimozione degli elementi dal DOM.
Mantenere tutte le slide nel DOM ha un costo trascurabile per gallerie di poche decine di immagini, soprattutto grazie a loading="lazy", che rimanda il download delle immagini non visibili. Per gallerie molto grandi conviene invece renderizzare solo una finestra di slide attorno a quella corrente.
L'utilizzo nell'applicazione
Il componente si usa come qualunque altro componente React. L'elenco delle immagini è definito fuori dal componente App, così mantiene la stessa identità tra un rendering e l'altro, e la callback onChange è memoizzata con useCallback.
// src/App.jsx
import { useCallback } from 'react';
import ImageSlideshow from './components/ImageSlideshow.jsx';
const IMAGES = [
{ src: '/images/mountains.jpg', alt: "Catena montuosa all'alba", caption: "Le montagne all'alba" },
{ src: '/images/lake.jpg', alt: 'Lago alpino circondato da abeti', caption: 'Un lago alpino' },
{ src: '/images/coast.jpg', alt: 'Costa rocciosa al tramonto', caption: 'La costa al tramonto' }
];
export default function App() {
const handleChange = useCallback(({ index, previousIndex }) => {
console.log(`Slide cambiata: da ${previousIndex + 1} a ${index + 1}`);
}, []);
return (
<ImageSlideshow
images={IMAGES}
interval={4000}
label="Galleria di viaggio"
onChange={handleChange}
/>
);
}
Testare il reducer
Uno dei benefici del reducer è la testabilità. Essendo una funzione pura, si verifica con semplici asserzioni, senza montare componenti né simulare il DOM. Ecco alcuni test scritti con Vitest.
// src/hooks/useSlideshow.test.js
import { describe, expect, it } from 'vitest';
import { initialState, slideshowReducer } from './useSlideshow.js';
describe('slideshowReducer', () => {
it('torna alla prima slide dopo l\'ultima con il loop attivo', () => {
const state = { index: 2, direction: 'next' };
const result = slideshowReducer(state, { type: 'next', total: 3, loop: true });
expect(result).toEqual({ index: 0, direction: 'next' });
});
it('va all\'ultima slide indietreggiando dalla prima', () => {
const result = slideshowReducer(initialState, { type: 'prev', total: 3, loop: true });
expect(result).toEqual({ index: 2, direction: 'prev' });
});
it('si ferma agli estremi senza loop', () => {
const state = { index: 2, direction: 'next' };
const result = slideshowReducer(state, { type: 'next', total: 3, loop: false });
// Lo stato invariato è lo stesso oggetto
expect(result).toBe(state);
});
it('corregge l\'indice quando le slide diminuiscono', () => {
const state = { index: 4, direction: 'next' };
const result = slideshowReducer(state, { type: 'clamp', total: 2, loop: true });
expect(result.index).toBe(1);
});
});
Il test che usa toBe invece di toEqual verifica l'identità dell'oggetto, cioè che il reducer restituisca esattamente lo stesso stato quando non c'è nulla da cambiare. È una proprietà importante, perché è ciò che permette a React di evitare rendering inutili.
Conclusioni
La versione React dello slideshow mette in luce le caratteristiche distintive della libreria. Lo stato di navigazione è gestito da un reducer puro e testabile, i valori derivati come isRunning sono semplici espressioni ricalcolate a ogni rendering, e le sorgenti esterne come le media query e la visibilità della pagina vengono sottoscritte con useSyncExternalStore. Il hook useInterval risolve il problema delle closure obsolete conservando la callback in un ref, e rende la pausa dichiarativa grazie al ritardo null.
Rispetto a Vue, React richiede più attenzione all'identità delle funzioni e alle dipendenze degli effetti, ma in cambio offre un modello estremamente prevedibile: l'interfaccia è sempre una funzione dello stato, e ogni effetto collaterale è dichiarato in modo esplicito. Nell'ultimo articolo della serie vedremo come Angular affronta lo stesso problema, con i signal, l'iniezione delle dipendenze e un approccio più strutturato all'architettura dei componenti.