Nękany młody mężczyzna pracujący w domu, siedzący na kanapie, rozmawiający przez telefon komórkowy podczas pisania notatek i korzystania z laptopa

Jak powiadomić użytkownika o niezapisanych zmianach? Zdarzenie beforeunload

10 min. czytania

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:

  1. Inicjujemy flagę isDirty = false.
  2. Gdy użytkownik wprowadzi zmianę w formularzu – ustawiamy isDirty = true.
  3. Gdy zapisze zmiany – ustawiamy isDirty = false.
  4. W beforeunload sprawdzamy tę flagę. Jeśli jest true, 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 beforeunload zwykłym confirm() – systemowe okno beforeunload jest 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 beforeunload tylko jako „ostatniej linii obrony”.