Hooks
useUnsavedChanges, détection des modifications non enregistrées et interception de la navigation.
Import
import { useUnsavedChanges } from '@lrvo/core/shared/hooks';Sous-chemin client.
useUnsavedChanges({ initial, current, onAttemptedNavigation })
Compare un instantané initial à l'état current du formulaire, par JSON.stringify. Retourne { isDirty, reset }.
Pour le cas standard (formulaire pleine page avec barre flottante) utiliser plutôt le composant UnsavedChangesBar, qui encapsule ce hook, l'animation de rappel et le rendu. Le hook reste exposé pour piloter une UI maison à partir d'isDirty.
| Retour | Rôle |
|---|---|
isDirty | true tant que initial et current diffèrent. |
reset() | Retourne l'instantané initial. L'appelant fait setFormData(reset()). |
Interception de la navigation
Tant qu'isDirty est vrai, la sortie de page est interceptée à trois niveaux.
| Niveau | Mécanisme |
|---|---|
Clic sur un <a href>, y compris ceux rendus par next/link | preventDefault et appel de onAttemptedNavigation. |
| Bouton précédent ou suivant du navigateur | Un état miroir est poussé au montage de la garde, puis re-poussé à chaque popstate, ce qui annule la navigation et déclenche onAttemptedNavigation. |
| Fermeture d'onglet, rechargement, navigation hors de l'application | Dialogue natif via beforeunload. |
Un clic est ignoré par la garde quand il porte une touche modificatrice, vise un autre onglet, porte l'attribut download, ou pointe vers une ancre pure : ce sont des gestes qui ne quittent pas la page.
Limitation : la navigation programmatique
router.push, router.replace et router.back ne sont pas interceptés.
En Next.js 16, le routeur App Router valide son état en amont et ne synchronise history.pushState que plus tard, depuis un useInsertionEffect : patcher pushState arriverait trop tard et violerait les règles de React.
Conséquence pratique : toute UI navigationnelle doit passer par un <a href>. Boutons retour, fil d'Ariane, items de sidebar : utiliser <Link> directement, ou <Button href> qui rend un <Link> sous le capot.
Les router.push hors UI (redirection après enregistrement, déconnexion) restent volontairement non bloqués.
Navigation dure après un enregistrement
window.location.href, location.assign et location.reload ne sont pas des navigations du routeur : elles déchargent le document, donc le niveau beforeunload du garde s'applique. Une page de création qui redirige ainsi vers la ressource créée demande confirmation alors que les données sont déjà enregistrées, parce qu'initial et current diffèrent encore à cet instant.
Redirection après un enregistrement : mettre initial à jour, puis router.push.
const created = await persist(form);
setInitial(form); // le garde n'a plus rien à protéger
router.push(`/admin/pages/edit/${created.id}`);Découplage
Le hook ne possède pas l'état du formulaire. L'appelant gère son useState ; le hook fournit reset().
Après un enregistrement réussi, l'appelant doit pousser le nouvel initial, en mettant son état d'instantané à jour avec les données post-enregistrement, pour qu'isDirty repasse à false.
const [initial, setInitial] = useState(loaded);
const [form, setForm] = useState(loaded);
const { isDirty, reset } = useUnsavedChanges({ initial, current: form, onAttemptedNavigation: shake });
async function save() {
const saved = await persist(form);
setInitial(saved); // sans cette ligne, la barre ne disparaît jamais
setForm(saved);
}Navigation interne non liée
Un changement d'onglet, une étape d'assistant ou la fermeture d'un tiroir ne passent pas par un <a href>, donc le hook ne peut pas les intercepter.
Passer un ref à UnsavedChangesBar : il expose { isDirty, shake() } par useImperativeHandle. Le parent vérifie isDirty avant la transition et appelle shake() pour rejouer l'animation quand la transition est bloquée.