Nella lezione precedente hai organizzato DevNotes in componenti — Header, NoteCard, NoteList — e hai capito come le props portano i dati dall’alto verso il basso nell’albero. I componenti però erano statici: mostravano dati fissi e non reagivano a nulla. Un click sul pulsante “elimina” non faceva niente. Il campo di ricerca era decorativo.
Questo è il limite dei componenti senza stato. In questa lezione lo risolvi: introduci useState, gestisci gli eventi utente, sollevi lo stato al genitore comune e usi useEffect per sincronizzare il componente con il mondo esterno. Dopo questa lezione DevNotes sarà una vera applicazione interattiva.
Aggiungiamo il componente NoteForm con input controllati e validazione, implementiamo l’aggiunta e l’eliminazione di note tramite useState in App.jsx, e usiamo useEffect per aggiornare il titolo del browser in tempo reale. Lungo la strada capiamo perché l’immutabilità non è un capriccio stilistico.
Cos’è lo stato — e perché non basta una variabile
Quando hai scritto NoteCard in L02, i dati arrivavano come props e il componente li leggeva soltanto. Ma ora vuoi che l’utente clicchi un pulsante e la nota sparisca dalla lista. Istinto naturale: tieni una variabile, la modifichi al click, l’interfaccia si aggiorna. Proviamo.
// ❌ Approccio intuitivo — non funziona function Counter() { let count = 0 return ( <div> <p>Hai cliccato {count} volte</p> <button onClick={() => count++}>Incrementa</button> </div> ) }
Clicchi il pulsante: count sale a 1, poi a 2 — ma sullo schermo rimane sempre zero. Perché? Perché React non ha ricevuto nessuna notifica. Quando riesegue la funzione Counter() per aggiornare il DOM virtuale, count riparte da zero. React non osserva le variabili locali. Produce l’interfaccia, si ferma, e aspetta che qualcuno lo avvisi esplicitamente che qualcosa è cambiato.
Lo stato in React è un valore che persiste tra un rendering e l’altro e che, quando cambia, pianifica un nuovo rendering del componente. La funzione useState restituisce il valore corrente e la funzione setter: chiamare il setter è l’unico canale ufficiale per notificare React che qualcosa è cambiato.
useState — anatomia e rendering
La sintassi è semplice: chiami useState con il valore iniziale e destrutturi il risultato in un array di due elementi.
import { useState } from 'react' function Counter() { // count → il valore corrente (inizia a 0) // setCount → la funzione per aggiornarlo const [count, setCount] = useState(0) return ( <div> <p>Hai cliccato {count} volte</p> <button onClick={() => setCount(count + 1)}>Incrementa</button> </div> ) }
Quando clicchi il pulsante, setCount notifica React che il valore è cambiato. React pianifica un nuovo rendering di Counter: la funzione viene rieseguita, e questa volta useState(0) non restituisce 0 — restituisce il valore aggiornato salvato internamente. Solo il nodo del DOM che mostra il contatore viene toccato; il pulsante rimane intatto. Questo è il diffing del virtual DOM al lavoro.
Aggiornamento funzionale — quando usarlo
Se il nuovo valore dipende da quello precedente, usa sempre la forma funzionale del setter. React può raggruppare più aggiornamenti in batch, quindi il valore di count nella closure potrebbe essere già obsoleto nel momento in cui viene letto.
// ❌ Pericoloso: usa il valore della closure (potenzialmente stale) setCount(count + 1) // ✅ Sicuro: prev è garantito essere l'ultimo valore aggiornato setCount(prev => prev + 1) // Esempio concreto: tre setCount consecutivi nello stesso handler <button onClick={() => { setCount(prev => prev + 1) setCount(prev => prev + 1) setCount(prev => prev + 1) // risultato: +3 ✅ (senza forma funzionale: +1) }}>+3</button>
Stato complesso — oggetti e array
React usa il confronto per riferimento per capire se lo stato è cambiato. Modificare direttamente un oggetto o un array lascia invariato il riferimento: React non vede nessuna differenza e non ri-renderizza. È il bug invisibile più comune per chi inizia.
const [nota, setNota] = useState({ titolo: '', testo: '' }) // ❌ Sbagliato: muta l'oggetto → stesso riferimento → nessun re-render nota.titolo = 'Nuova' setNota(nota) // ✅ Corretto: spread crea un nuovo oggetto → riferimento diverso → re-render setNota({ ...nota, titolo: 'Nuova' }) // ─────────────────────────────────────────────── const [lista, setLista] = useState([]) // ❌ Sbagliato: push muta l'array originale lista.push({ id: 1, testo: 'Ciao' }) setLista(lista) // ✅ Aggiungere — spread produce un nuovo array setLista(prev => [...prev, { id: 1, testo: 'Ciao' }]) // ✅ Rimuovere per id — filter restituisce sempre un nuovo array setLista(prev => prev.filter(n => n.id !== idDaRimuovere)) // ✅ Modificare un elemento — map restituisce sempre un nuovo array setLista(prev => prev.map(n => n.id === id ? { ...n, testo: 'Modificato' } : n))
Non mutare mai lo stato. Per gli oggetti usa lo spread { ...obj, chiave: nuovoValore }. Per gli array usa filter(), map() e lo spread [...arr]. Queste operazioni producono sempre un nuovo riferimento — che è esattamente ciò che React controlla per decidere se ri-renderizzare.
Gestione degli eventi
React normalizza gli eventi del browser in un sistema di SyntheticEvent: un wrapper cross-browser che espone la stessa API su Chrome, Firefox e Safari. Nella pratica usi gli stessi nomi dell’HTML — ma in camelCase e come props JSX, non come attributi stringa.
| HTML | React JSX | Quando scatta |
|---|---|---|
onclick | onClick | Click del mouse / tap mobile |
onchange | onChange | Ogni modifica a un input |
onsubmit | onSubmit | Invio di un form |
onkeydown | onKeyDown | Tasto premuto |
onfocus / onblur | onFocus / onBlur | Focus / perdita focus |
onmouseenter | onMouseEnter | Hover |
Componenti controllati
Un componente controllato è un <input> il cui valore è sempre determinato dallo stato React — non dal DOM. È il pattern fondamentale per gestire form in React: lo stato è l’unica fonte di verità, e ogni battitura aggiorna lo stato che a sua volta aggiorna il campo.
// NoteForm.jsx — form con input controllati import { useState } from 'react' function NoteForm({ onAggiungi }) { const [titolo, setTitolo] = useState('') const [testo, setTesto] = useState('') const [errore, setErrore] = useState('') function handleSubmit(e) { e.preventDefault() // blocca il reload della pagina if (!titolo.trim()) { setErrore('Il titolo è obbligatorio.') return } onAggiungi({ titolo: titolo.trim(), testo: testo.trim() }) setTitolo('') // reset dopo l'invio setTesto('') setErrore('') } return ( <form onSubmit={handleSubmit} noValidate> <input value={titolo} // ← controllato dallo stato onChange={e => setTitolo(e.target.value)} // ← ogni tasto aggiorna lo stato placeholder="Titolo della nota" /> <textarea value={testo} onChange={e => setTesto(e.target.value)} placeholder="Scrivi qui il contenuto..." /> <button type="submit" disabled={!titolo.trim()}>+ Aggiungi nota</button> {errore && <p className="errore">{errore}</p>} </form> ) } export default NoteForm
Con un input controllato lo stato React è la sola fonte di verità. Puoi validare al volo, trasformare l’input (es. uppercase), disabilitare il pulsante se il campo è vuoto, o azzerare il form dopo l’invio — tutto senza toccare il DOM direttamente.
Lift state up — sollevare lo stato
In DevNotes il form NoteForm aggiunge note, e NoteList le mostra. I due componenti sono fratelli: nessuno dei due può leggere lo stato dell’altro. La soluzione è sollevare lo stato al genitore comune — App.jsx — e passarlo ai figli tramite props.
// App.jsx — lo stato note[ ] vive qui import { useState } from 'react' import NoteForm from './components/NoteForm/NoteForm' import NoteList from './components/NoteList/NoteList' function App() { const [note, setNote] = useState([ { id: 1, titolo: 'Prima nota', testo: 'Contenuto...', tag: 'idea', data: new Date() }, ]) function aggiungiNota({ titolo, testo }) { const nuova = { id: Date.now(), titolo, testo, tag: 'idea', data: new Date() } setNote(prev => [nuova, ...prev]) // nuova in cima } function eliminaNota(id) { setNote(prev => prev.filter(n => n.id !== id)) } return ( <main> <NoteForm onAggiungi={aggiungiNota} /> <NoteList note={note} onElimina={eliminaNota} /> </main> ) } export default App—
useEffect — sincronizzare con il mondo esterno
Tutto ciò che hai scritto finora è puro: dati in ingresso → JSX in uscita. Ma le applicazioni reali devono interagire con cose esterne al flusso di React: il titolo del documento, le API, i timer, i WebSocket. Queste interazioni si chiamano side effects — effetti collaterali — perché escono dal flusso di rendering puro.
useEffect(fn, deps) esegue la funzione fn dopo il rendering, ogni volta che uno dei valori in deps cambia. Se deps è un array vuoto [], l’effetto gira una sola volta al mount. Se deps viene omesso, gira ad ogni rendering. Il valore di ritorno di fn, se presente, è la funzione di cleanup.
Le tre forme di useEffect
import { useEffect } from 'react' // 1. Senza array → esegue dopo OGNI rendering (raramente utile) useEffect(() => { console.log('Ogni re-render') }) // 2. Array vuoto [] → esegue UNA SOLA VOLTA al mount useEffect(() => { console.log('Componente montato') }, []) // 3. [deps] → mount + ogni volta che deps cambia useEffect(() => { document.title = `DevNotes — ${note.length} note` }, [note.length]) // ← si aggiorna ogni volta che il numero di note cambia
Il cleanup — evitare i memory leak
Alcuni effetti aprono risorse — timer, listener, connessioni — che devono essere chiuse quando il componente viene rimosso o quando l’effetto viene rieseguito. useEffect supporta una funzione di cleanup: basta restituirla dall’interno dell’effetto.
// Orologio.jsx — timer con cleanup corretto import { useState, useEffect } from 'react' function Orologio() { const [ora, setOra] = useState(new Date()) useEffect(() => { // avvia il timer al mount const id = setInterval(() => setOra(new Date()), 1000) // cleanup: React chiama questa funzione prima di smontare il componente // senza questo return, il timer continua a girare anche a componente rimosso return () => clearInterval(id) }, []) return <p>{ora.toLocaleTimeString()}</p> }
Se dimentichi il cleanup di un setInterval o di un addEventListener, la risorsa continua a vivere in memoria anche dopo che il componente è sparito dal DOM. In un’applicazione con tante route questo causa memory leak progressivi e aggiornamenti di stato su componenti già smontati — che React segnala con un warning in console.
useEffect e le chiamate fetch
Un pattern che incontrerai spesso è usare useEffect per caricare dati al mount del componente. In L06 costruiremo un custom hook dedicato; per ora è importante capire la struttura base e il perché del cleanup con AbortController.
useEffect(() => { // AbortController permette di cancellare la richiesta in volo const controller = new AbortController() async function caricaNote() { try { const res = await fetch('/api/note', { signal: controller.signal }) const data = await res.json() setNote(data) } catch (err) { if (err.name !== 'AbortError') setErrore(err.message) } } caricaNote() // cleanup: se il componente viene smontato prima della risposta, cancella la fetch return () => controller.abort() }, [])—
Schema mentale — ciclo di vita e hooks
React non usa più i metodi di lifecycle delle classi (componentDidMount ecc.), ma il modello mentale è ancora utile per sapere esattamente quando vengono eseguiti gli effetti.
useEffect(fn, [])
Esegue una sola volta dopo il primo rendering
useEffect(fn, [dep])
Mount + ogni volta che dep cambia
return () => fn()
Cleanup eseguito prima di smontare
Le regole degli Hooks
Gli Hooks hanno due regole inviolabili. Il plugin ESLint eslint-plugin-react-hooks — già incluso nel template Vite — ti segnala in real-time se le violi. Non ignorare mai quei warning: quasi sempre indicano un bug reale.
| # | Regola | Perché |
|---|---|---|
| 1 | Chiama gli Hooks solo al top level | Non dentro if, loop o funzioni annidate. React si affida all’ordine di chiamata per associare lo stato corretto a ogni Hook: se l’ordine cambia tra i rendering, tutto si rompe. |
| 2 | Chiama gli Hooks solo da componenti React o Hook personalizzati | Non da funzioni JavaScript normali. Questo mantiene la logica con stato tracciabile, testabile e visibile in React DevTools. |
// ❌ Violazione regola 1 — Hook dentro un if function MyComponent({ attivo }) { if (attivo) { const [dato, setDato] = useState(0) // ← React non riesce a tracciare l'ordine } } // ✅ Corretto — Hook sempre chiamato, condizione dentro il corpo function MyComponent({ attivo }) { const [dato, setDato] = useState(0) if (!attivo) return null // ... resto del componente }—
Il file lab-03-state-hooks.md ti guida nel creare NoteForm.jsx con input controllati e validazione, implementare aggiunta ed eliminazione note in App.jsx, aggiungere la ricerca in tempo reale con useMemo e costruire un componente con timer e cleanup verificabile in React DevTools.
- useState restituisce
[valore, setter]. Chiamare il setter notifica React e pianifica un re-render. Le variabili locali normali non triggherano nessun aggiornamento. - Non mutare mai lo stato. Usa spread per gli oggetti,
filter()emap()per gli array: queste operazioni producono un nuovo riferimento, che è il segnale che React controlla. - Quando il nuovo valore dipende da quello precedente, usa la forma funzionale
setState(prev => ...)per evitare problemi con il batching. - I componenti controllati tengono il valore degli input nello stato React: ogni tasto aggiorna lo stato, che aggiorna il campo. Lo stato è l’unica fonte di verità.
- Lift state up: quando due componenti fratelli condividono dati, lo stato sale al genitore comune e scende ai figli tramite props.
useEffect(fn, [])gira al mount,useEffect(fn, [dep])gira al mount e quandodepcambia. Il valore di ritorno è la funzione di cleanup: sempre necessaria con timer, listener e fetch.- Regole degli Hooks: top level, solo dentro componenti React o Hook personalizzati. ESLint ti avvisa in tempo reale se le violi.