Powiadamianie użytkownika o niezapisanych zmianach to dziś standard dobrego UX – zwłaszcza w rozbudowanych formularzach, panelach administracyjnych czy aplikacjach typu single‑page. JavaScript udostępnia do tego specjalne zdarzenie beforeunload, które pozwala wstrzymać opuszczenie strony i wyświetlić natywne okno dialogowe przeglądarki z ostrzeżeniem.
Poniżej znajdziesz rozbudowany, praktyczny przewodnik: od podstaw działania beforeunload, przez gotowe fragmenty kodu, po kwestie użyteczności i dostępności (a11y).
Problem – utrata niezapisanych danych
Typowe scenariusze:
- długi formularz (wniosek, profil, konfiguracja),
- przypadkowe zamknięcie karty lub całej przeglądarki,
- kliknięcie odnośnika do innej strony,
- odświeżenie strony skrótem klawiaturowym,
- edytor treści w CMS/blogu, gdzie utrata dłuższego tekstu jest szczególnie bolesna.
Bez dodatkowych zabezpieczeń użytkownik po prostu traci dane.
Rozwiązanie: nasłuchać zdarzenia wychodzenia ze strony i – tylko jeśli wykryliśmy niezapisane zmiany – pokazać ostrzeżenie.
Czym jest zdarzenie beforeunload?
Zdarzenie beforeunload odpala się, gdy okno/karta przeglądarki ma za chwilę wyładować zasoby – tuż przed zdarzeniem unload.
Obejmuje to m.in.:
- zamknięcie karty lub całej przeglądarki,
- odświeżenie strony (F5, Ctrl+R),
- przejście na inny adres (link, wpisanie adresu w pasku, nawigacja wstecz/dalej).
W trakcie obsługi beforeunload dzieją się trzy rzeczy:
- dokument jest jeszcze widoczny dla użytkownika,
- można wstrzymać proces opuszczania strony,
- przeglądarka wyświetla natywne okno dialogowe z prośbą o potwierdzenie.
Mechanizm ten jest wykorzystywany właśnie do ostrzegania o niezapisanych zmianach.
Kluczowe zasady i ograniczenia
Jak przeglądarka decyduje, czy pokazać dialog?
Aby przeglądarka wyświetliła ostrzeżenie, w obsłudze beforeunload ustaw e.returnValue na pusty lub niepusty string; dodatkowo, dla zgodności wstecznej, możesz zwrócić stringa z funkcji obsługującej zdarzenie.
Przykłady z różnych źródeł:
// wariant z addEventListener
window.addEventListener('beforeunload', function (e) {
e.preventDefault(); // zalecane w nowszych przeglądarkach
e.returnValue = ''; // informacja dla przeglądarki, że chcemy dialog
});
// wariant z przypisaniem handlera
window.onbeforeunload = function (e) {
var message = 'Masz niezapisane zmiany.';
(e || window.event).returnValue = message; // Gecko + IE
return message; // inne przeglądarki
};
W praktyce (stan na dziś, według specyfikacji i realnych przeglądarek):
- dialog jest zawsze natywny (systemowy),
- w wielu przeglądarkach tekst wiadomości jest ignorowany – wyświetlany jest standardowy komunikat („Czy na pewno chcesz opuścić stronę?”),
- istotne jest samo ustawienie
returnValue(na cokolwiek nie-undefined).
To celowe ograniczenie anty‑spamowe: strony nie mogą zmieniać wyglądu tego okna, by nie blokować użytkowników manipulacyjnymi komunikatami.
Zdarzenie wywołuje tylko przeglądarka
beforeunload odpala się tylko przy prawdziwym opuszczaniu strony – nie da się go „sztucznie” wywołać w sposób, który pokaże użytkownikowi dialog.
Wywołanie:
window.dispatchEvent(new Event('beforeunload'));
nie spowoduje systemowego okna; zadziała ono tylko przy realnym zamknięciu/przeładowaniu.
Nie nadużywaj – tylko przy realnych zmianach
Źródła podkreślają, że nie należy ostrzegać użytkownika, jeśli nic nie zmienił. Dlatego zawsze łączymy beforeunload z mechanizmem śledzenia zmian („dirty flag”, flaga „brudnej” strony).
Minimalna implementacja: flaga isDirty
Najprostszy schemat wygląda tak:
- Inicjujemy flagę
isDirty = false. - Gdy użytkownik wprowadzi zmianę w formularzu – ustawiamy
isDirty = true. - Gdy zapisze zmiany – ustawiamy
isDirty = false. - W
beforeunloadsprawdzamy tę flagę. Jeśli jesttrue, zwracamy komunikat → przeglądarka pokazuje dialog.
Przykład w czystym JavaScripcie
Załóżmy prosty formularz:
<form id="profile-form">
<label>
Imię:
<input type="text" name="firstName">
</label>
<label>
Nazwisko:
<input type="text" name="lastName">
</label>
<button type="submit">Zapisz</button>
</form>
JavaScript:
let isDirty = false;
const form = document.getElementById('profile-form');
// 1. Śledzenie zmian w formularzu
form.addEventListener('input', () => {
isDirty = true;
});
// 2. Po wysłaniu formularza uznajemy dane za zapisane
form.addEventListener('submit', () => {
isDirty = false;
});
// 3. Ostrzeżenie przy opuszczaniu strony
window.addEventListener('beforeunload', (e) => {
if (!isDirty) {
return; // nic nie rób, jeśli brak zmian
}
e.preventDefault(); // zgodnie z nowszym API
e.returnValue = ''; // informuje przeglądarkę, że ma pokazać dialog
// Zwracanie tekstu jest dziś głównie dla zgodności:
return '';
});
Ten kod nie przeszkadza, gdy użytkownik tylko ogląda stronę, a ostrzeże go wyłącznie wtedy, gdy istnieją niezapisane zmiany.
„Dirty flag” w praktyce – kilka wariantów
Flaga sterowana zdarzeniami formularza
Przykład koncepcyjny z odpowiedzi Stack Overflow:
let modified = false;
// Ustawiamy modified = true podczas edycji:
document.querySelectorAll('input, textarea, select')
.forEach(el => {
el.addEventListener('change', () => {
modified = true;
});
});
// Minimalistyczny handler:
window.onbeforeunload = (e) => modified ? '' : null;
Tutaj, jeśli modified ma wartość true, przeglądarka pokaże dialog; ustawienie null oznacza „nie rób nic”.
Wersja z jQuery i ukrytym polem
Niektóre rozwiązania używają ukrytego pola input o nazwie np. UnsavedChanges:
<input type="hidden" id="UnsavedChanges" value="0">
JavaScript (jQuery):
function markDirty() {
$('#UnsavedChanges').val('1');
}
// reagujemy na różne typy pól
$('input, select, textarea').on('change', markDirty);
$('input:checkbox, input:radio').on('click', markDirty);
window.onbeforeunload = function () {
if ($('#UnsavedChanges').val() === '1') {
const msg = 'Masz niezapisane zmiany. Czy na pewno chcesz wyjść bez zapisywania?';
return msg; // uruchamia natywny dialog przeglądarki
}
};
Mechanizm jest ten sam, tylko stan przechowywany jest w polu formularza – co bywa wygodne przy integracji z backendem.
Porównywanie stanu formularza (serialize)
Inny sposób to zapamiętanie „początkowego” stanu i porównywanie go przed opuszczeniem strony:
let initialFormState;
$(function () {
initialFormState = $('#myform').serialize(); // początkowy stan formularza
$(window).on('beforeunload', function (e) {
const currentState = $('#myform').serialize();
if (initialFormState !== currentState) {
const message = 'Masz niezapisane zmiany. Opuścić stronę i je utracić?';
e.returnValue = message; // zgodność z różnymi przeglądarkami
return message; // wywołuje dialog
}
});
});
Plusy: nie trzeba podpinać się pod każde pole z osobna; działa także, gdy pola są dynamicznie dodawane.
Minusy: może być mniej wydajne przy bardzo dużych formularzach; trzeba uważać przy polach zmieniających się „same” (np. timestamp, CSRF token).
UX – jak nie wkurzyć użytkowników?
Z technicznego punktu widzenia wystarczy kilka linijek, ale dobry UX wymaga więcej:
- ostrzegaj tylko wtedy, gdy to konieczne – wszystkie źródła podkreślają, aby nie pokazywać komunikatu, jeśli użytkownik nic nie zmienił,
- nie zastępuj
beforeunloadzwykłymconfirm()– systemowe oknobeforeunloadjest jedynym, które na pewno powstrzyma nawigację, - rozważ automatyczne zapisywanie (autosave) lub zapisywanie szkiców – zmniejsza to częstość, z jaką w ogóle musisz ostrzegać,
- informuj wcześniej – np. stały pasek ostrzeżenia nad formularzem („Masz niezapisane zmiany”), zamiast zaskakiwać użytkownika dopiero przy zamknięciu karty.
Przykładowy, lekki wzorzec:
- gdy
isDirty = true, pokazujesz na stronie bannerek „Masz niezapisane zmiany”, - gdy użytkownik klika wewnętrzne linki w aplikacji – pokazujesz własny modal z pytaniem, co dalej,
- gdy użytkownik zamyka kartę/odświeża – odpala się
beforeunload.
Dostępność (a11y) a ostrzeżenia o niezapisanych zmianach
Natywny dialog beforeunload a czytniki ekranu
Natywne okno przeglądarki jest co do zasady dostępne:
- przejmuje fokus,
- jest ogłaszane przez czytniki ekranu,
- ma znane użytkownikom klawisze skrótów.
Jednocześnie:
- nie mamy wpływu na jego strukturę semantyczną,
- nie możemy dodać własnych atrybutów ARIA,
- w wielu przeglądarkach nie mamy kontroli nad dokładnym tekstem.
Dlatego w kontekście WCAG warto nie polegać wyłącznie na tym dialogu i zapewnić wcześniejsze, widoczne i słyszalne wskazanie, że dane są niezapisane.
Dostępny pasek/komunikat o niezapisanych zmianach
Dla dostępności dobrze jest dodać nad formularzem pasek – np. tak:
<div id="unsaved-warning" role="status" aria-live="polite" class="unsaved-warning" hidden>
Masz niezapisane zmiany. Nie opuszczaj strony bez zapisania.
</div>
Gdy isDirty zmienia się z false na true, usuń atrybut hidden – czytnik ekranu odczyta komunikat dzięki aria-live="polite". Przy przejściu true → false możesz pasek ukryć lub zmienić komunikat na „Wszystkie zmiany zapisane”.
Takie rozwiązanie pomaga użytkownikom niewidomym, słabowidzącym i z trudnościami poznawczymi oraz spełnia wymogi informowania o krytycznych zdarzeniach w sposób programowo rozpoznawalny.
Własne modale przy nawigacji wewnętrznej
Jeśli masz SPA lub złożony panel, często chcesz ostrzec użytkownika także przy kliknięciu w inną zakładkę w obrębie aplikacji czy zmianie routingu po stronie klienta. beforeunload wtedy nie zadziała, bo strona faktycznie nie jest opuszczana.
Rozwiązanie: przechwyć kliknięcia w linki/zmiany routingu i, jeśli isDirty = true, pokaż własne okno dialogowe (np. <div role="dialog" aria-modal="true">).
Zadbaj o:
- ustawienie fokusu wewnątrz dialogu,
- pułapkę fokusu (TAB nie ucieka na elementy w tle),
- możliwość zamknięcia klawiszem ESC,
- jasno opisane przyciski (np. „Opuść bez zapisywania”, „Zostań i zapisz”).
Integracja z logiką aplikacji
Obsługa zapisu asynchronicznego
Po kliknięciu „Zapisz” możesz tymczasowo wyłączyć beforeunload, aby ewentualne przekierowanie po zapisie nie uruchomiło ostrzeżenia.
Po powodzeniu zapisu ustaw isDirty = false, a następnie ponownie włącz beforeunload dla kolejnych zmian.
Przykład:
let isDirty = false;
function enableBeforeUnload() {
window.addEventListener('beforeunload', beforeUnloadHandler);
}
function disableBeforeUnload() {
window.removeEventListener('beforeunload', beforeUnloadHandler);
}
function beforeUnloadHandler(e) {
if (!isDirty) return;
e.preventDefault();
e.returnValue = '';
}
enableBeforeUnload();
// przy każdej zmianie:
function onChange() {
isDirty = true;
}
// przy zapisie:
async function onSave() {
disableBeforeUnload();
try {
await saveToServer(); // własna funkcja zapisu
isDirty = false;
} finally {
enableBeforeUnload();
}
}
Wyjątki – akcje, które same w sobie zapisują dane
Czasem masz przyciski typu „Opublikuj i wróć do listy” lub „Zapisz i zamknij”. O ile faktycznie dane są zapisywane i aplikacja gwarantuje brak utraty danych, możesz dla tych ścieżek chwilowo wyłączyć beforeunload i przeprowadzić przekierowanie bez pokazywania ostrzeżenia.
Przykładowy szablon rozwiązania „produkcyjnego”
Poniżej uproszczony, ale bliski „produkcyjnemu” wzorzec – z paskiem ostrzeżenia i obsługą dostępności.
HTML:
<div id="unsaved-warning" role="status" aria-live="polite" class="unsaved-warning" hidden>
Masz niezapisane zmiany. Zapisz je przed opuszczeniem strony.
</div>
<form id="data-form">
<!-- pola formularza -->
<button type="submit">Zapisz</button>
</form>
CSS (przykładowo):
.unsaved-warning {
background: #fff3cd;
color: #856404;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
border: 1px solid #ffeeba;
border-radius: 4px;
}
JS:
const form = document.getElementById('data-form');
const warningBar = document.getElementById('unsaved-warning');
let isDirty = false;
function setDirty(value) {
if (isDirty === value) return;
isDirty = value;
if (isDirty) {
warningBar.hidden = false;
warningBar.textContent = 'Masz niezapisane zmiany. Zapisz je przed opuszczeniem strony.';
} else {
warningBar.hidden = true;
}
}
// zmiany w formularzu
form.addEventListener('input', () => setDirty(true));
// wysłanie formularza
form.addEventListener('submit', (e) => {
// tutaj normalna logika zapisu (fetch / klasyczny submit)
setDirty(false);
});
// ostrzeżenie przy opuszczaniu strony
window.addEventListener('beforeunload', (e) => {
if (!isDirty) return;
e.preventDefault();
e.returnValue = '';
});
Ten wzorzec:
- używa czytelnej flagi
isDirty(wartość prawda/fałsz), - zapewnia widoczny i dostępny komunikat na stronie,
- korzysta z
beforeunloadtylko jako „ostatniej linii obrony”.






