Ada dua pertanyaan yang diam-diam mengatur begitu banyak perilaku JavaScript: di mana sebuah kode boleh membaca variabel tertentu, dan berapa lama variabel itu tetap hidup? Jawaban pertanyaan pertama adalah scope. Jawaban pertanyaan kedua — begitu kamu mulai melibatkan fungsi — membawamu langsung ke closure, salah satu fitur yang paling sering bikin orang bingung sampai akhirnya tiba-tiba “klik”.
Kabar baiknya, kedua konsep ini sebenarnya nggak sulit asalkan kamu berhenti menghafal aturan dan mulai membayangkan apa yang sebenarnya terjadi. Di akhir artikel ini kamu bakal bisa melihat sepotong kode, melacak variabel mana saja yang bisa dijangkau tiap barisnya, dan menjelaskan kenapa sebuah counter yang kamu tulis tiga baris lalu masih mengingat angka pribadinya sendiri. Kita akan banyak mengandalkan apa yang sudah kamu tahu soal fungsi dan variabel, jadi kalau dua hal itu masih goyah, baca sekilas dulu ya.
Apa arti scope sebenarnya
Scope adalah kumpulan variabel yang terlihat — bisa dipanggil namanya — dari sebuah titik di dalam kodemu. Nggak semua variabel tersedia di mana-mana. JavaScript menarik garis-garis batas, dan sebuah variabel cuma ada di dalam batas tempat ia dideklarasikan.
Bayangkan seperti ruangan di dalam sebuah gedung. Kalau kamu berdiri di sebuah ruangan kecil, kamu bisa melihat semua isi ruangan itu plus semua isi ruangan yang lebih besar di sekelilingnya — tapi orang di ruangan sebelah nggak bisa melihat apa yang ada di mejamu. Kode bekerja persis begitu: area yang di dalam bisa memandang ke luar, tapi area yang di luar nggak bisa mengintip ke dalam.
Ada tiga jenis scope yang perlu kamu kenali namanya:
- Global scope — dideklarasikan di luar semua fungsi dan blok. Terlihat di mana-mana.
- Function scope — dideklarasikan di dalam sebuah fungsi. Cuma terlihat di dalam fungsi itu.
- Block scope — dideklarasikan di dalam sebuah blok
{ }(misalnyaif,for, atau sekadar sepasang kurung kurawal). Cuma terlihat di dalam blok itu. Aturan ini berlaku untukletdanconst, tapi tidak untukvar.
const namaApp = "ACY Partner Indonesia"; // global
function sapa() {
const pesan = "Selamat datang"; // function scope
console.log(pesan); // ✅ jalan
console.log(namaApp); // ✅ jalan — bisa memandang ke luar ke global
}
sapa();
console.log(namaApp); // ✅ "ACY Partner Indonesia"
console.log(pesan); // ❌ ReferenceError: pesan is not defined
pesan hidup di dalam sapa. Begitu kamu mencoba membacanya dari luar, JavaScript sama sekali nggak tahu apa yang kamu maksud — nama itu memang nggak ada di luar sana.
Block scope: let dan const vs var
Ini satu potong sejarah scope yang benar-benar perlu kamu tahu, karena kode lama dan kode baru berperilaku berbeda. Sebelum 2015, var adalah satu-satunya cara mendeklarasikan variabel, dan var sama sekali nggak peduli batas blok. Dia cuma menghormati scope fungsi.
function cek() {
if (true) {
var x = 10; // var mengabaikan blok { }
let y = 20; // let terkurung di dalam blok
}
console.log(x); // ✅ 10 — var bocor keluar dari blok if
console.log(y); // ❌ ReferenceError — let tetap di dalam
}
Perhatikan baik-baik: x dan y dideklarasikan di baris yang bersebelahan, di dalam if yang sama. Tapi x lolos keluar dari blok sementara y tidak. Itulah inti masalah var dalam satu contoh — dia longgar soal di mana ia tinggal, dan kelonggaran itu melahirkan bug yang susah dilacak.
Pakai const dulu, baru let, hindari var
Di JavaScript modern, deklarasikan semuanya dengan const lebih dulu. Kalau kamu memang butuh menugaskan ulang variabel itu nanti, baru ganti yang satu itu jadi let. var hampir nggak pernah kamu butuhkan — perilakunya yang mengabaikan blok justru lebih banyak bikin kaget ketimbang membantu. Membiasakan diri menyimpan var cuma untuk “jangan, kecuali lagi merawat kode lama” diam-diam menghapus satu kategori bug penuh dari pekerjaanmu.
Jebakan var di dalam loop yang klasik
Korban paling terkenal dari scope var yang longgar adalah loop yang membuat fungsi. Karena tiap iterasi memakai var i yang sama, semua fungsi akhirnya membaca nilai terakhirnya:
// Dengan var — ketiganya mencetak 3
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// mencetak: 3, 3, 3
// Dengan let — i baru di setiap iterasi
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// mencetak: 0, 1, 2
Dengan let, tiap putaran loop mendapat i yang benar-benar baru, jadi tiap fungsi menangkap angka yang berbeda. Perbedaan satu ini saja sudah jadi alasan cukup untuk meninggalkan var. (Jangan pusingkan dulu kenapa setTimeout-nya jalan belakangan — yang penting di sini adalah i mana yang dilihat tiap fungsi.)
Scope chain: cara JavaScript menemukan variabel
Saat kodemu memakai sebuah variabel, JavaScript perlu mencari tahu yang mana yang kamu maksud. Caranya dengan menyusuri scope chain: dia mengecek scope saat ini dulu, dan kalau namanya nggak ada di situ, dia melangkah ke luar ke scope di sekelilingnya, lalu ke luar lagi, terus sampai ke global. Yang cocok pertama kali itulah yang menang. Kalau sudah sampai global dan tetap nggak ketemu, kamu dapat ReferenceError.
const level = "global";
function luar() {
const level = "luar";
function dalam() {
console.log(level); // "luar"
}
dalam();
}
luar();
Di dalam dalam, JavaScript mencari level. Variabel itu nggak dideklarasikan di dalam, jadi dia melangkah keluar ke luar, menemukan level = "luar" di sana, lalu berhenti. level yang global nggak pernah sempat dilirik, karena pencariannya berhenti di kecocokan pertama. Pencarian yang cuma mengarah ke luar inilah mesin di balik semua hal lain di artikel ini.
Pencariannya cuma ke luar, nggak pernah ke dalam
Sebuah fungsi bisa membaca variabel dari scope-scope yang membungkusnya, tapi nggak pernah dari scope yang bersarang di dalamnya, dan nggak pernah dari fungsi yang sederajat. Kalau dalam mendefinisikan sebuah variabel, luar nggak bisa melihatnya. Rantai ini jalur satu arah yang menunjuk keluar menuju global — ingat arah ini, dan kebanyakan teka-teki “kok ini undefined?” bakal terpecahkan sendiri.
Closure: fungsi yang mengingat
Di sinilah semuanya menyatu. Closure adalah apa yang kamu dapat ketika sebuah fungsi tetap memegang variabel dari scope tempat ia diciptakan, bahkan setelah scope luar itu selesai dijalankan. Fungsi itu “menutup” (closes over) variabel-variabel tadi dan membawanya seperti ransel.
Itu kedengarannya abstrak, jadi mari kita lihat langsung:
function buatPenyapa(nama) {
// `nama` hidup di scope buatPenyapa
return function () {
console.log("Halo, " + nama + "!");
};
}
const sapaJohn = buatPenyapa("John Doe");
const sapaJane = buatPenyapa("Jane Doe");
sapaJohn(); // "Halo, John Doe!"
sapaJane(); // "Halo, Jane Doe!"
Coba berhenti sejenak dan perhatikan keanehannya. Pada saat kamu memanggil sapaJohn(), fungsi buatPenyapa sebenarnya sudah lama selesai — dia jalan, mengembalikan nilai, lalu keluar. Biasanya, begitu sebuah fungsi keluar, variabel lokalnya ikut lenyap. Tapi fungsi yang di dalam tadi masih tahu bahwa nama itu "John Doe". Itulah closure: fungsi yang dikembalikan menyimpan tautan hidup ke nama tempat ia dilahirkan, jadi variabelnya tetap bertahan melewati “kematian” fungsi induknya.
Dan karena tiap panggilan ke buatPenyapa menciptakan scope yang baru, sapaJohn dan sapaJane mengingat nama yang berbeda. Keduanya nggak berbagi state — tiap closure punya salinan pribadinya sendiri.
Counter pribadi (closure yang bakal benar-benar kamu pakai)
Contoh yang ada di buku teks, dan kebetulan memang berguna betulan, adalah sebuah counter yang menyembunyikan hitungannya dari bagian kode lainnya:
function buatCounter() {
let hitung = 0; // pribadi — nggak ada yang dari luar bisa menyentuhnya langsung
return {
naik() {
hitung++;
return hitung;
},
reset() {
hitung = 0;
},
};
}
const counter = buatCounter();
console.log(counter.naik()); // 1
console.log(counter.naik()); // 2
console.log(counter.naik()); // 3
counter.reset();
console.log(counter.naik()); // 1
hitung terkunci di dalam buatCounter. Nggak ada counter.hitung yang bisa dibaca atau nggak sengaja ditimpa dari luar — satu-satunya cara mengubahnya adalah lewat method naik dan reset yang diberikan kepadamu. Closure memberimu privasi data: sebuah nilai yang bertahan di antara pemanggilan, tapi cuma bisa disentuh lewat pintu yang terkontrol. Inilah fondasi dari banyak sekali pola nyata, mulai dari caching, modul, sampai event handler yang mengingat pengaturannya.
Closure menangkap variabelnya, bukan potretnya
Bagian ini sering bikin orang keliru: closure memegang referensi hidup ke variabelnya, bukan salinan beku dari nilainya saat closure dibuat. Kalau variabelnya berubah belakangan, closure ikut melihat nilai yang baru. Justru karena itulah hitung di atas terus naik dari panggilan ke panggilan, bukan kembali ke nilai awalnya — closure dan scope asalnya menatap hitung yang sama, bukan dua hitung yang terpisah.
Kenapa closure penting di kode nyata
Closure itu bukan sekadar trik sulap — kamu sebenarnya sudah memakainya terus-menerus, sering kali tanpa menyebut namanya. Beberapa tempat dia muncul setiap hari:
- Event handler yang perlu mengingat sesuatu dari saat ia dipasang:
function pasangTombol(label) {
const tombol = document.querySelector("#simpan");
tombol.addEventListener("click", () => {
// menutup `label` — masih tersedia saat klik terjadi nanti
console.log("Diklik: " + label);
});
}
- Pabrik fungsi (function factory) — membuat fungsi-fungsi khusus dari satu fungsi umum:
function kaliDengan(faktor) {
return (n) => n * faktor;
}
const kaliDua = kaliDengan(2);
const kaliTiga = kaliDengan(3);
console.log(kaliDua(5)); // 10
console.log(kaliTiga(5)); // 15
kaliDua selamanya mengingat faktor = 2; kaliTiga mengingat faktor = 3. Pabrik yang sama, dua fungsi dengan dua ingatan berbeda yang sudah tertanam. Begitu kamu mengenali pola ini, kamu bakal melihatnya di mana-mana di kode nyata — ini cara yang rapi untuk mengatur perilaku sejak awal.
Jebakan yang umum: variabel bocor jadi global
Satu jebakan terakhir yang menghubungkan scope kembali ke bug sehari-hari. Kalau kamu menugaskan nilai ke sebuah variabel yang nggak pernah kamu deklarasikan, di kode non-strict JavaScript diam-diam membuatnya sebagai variabel global — jauh di luar scope tempat kamu ingin ia tinggal:
function setup() {
config = { siap: true }; // ❌ tanpa let/const/var — jadi global!
}
setup();
console.log(config.siap); // true — config bocor ke scope global
Kamu mungkin bermaksud config lokal untuk setup. Tapi kenyataannya sekarang dia terlihat di mana-mana, siap bertabrakan dengan apa pun yang juga bernama config. Selalu deklarasikan variabelmu, dan pasang "use strict"; di bagian atas file — strict mode mengubah kesalahan diam-diam ini jadi ReferenceError yang nyaring, supaya kamu langsung menangkapnya.
Penutup
Sekarang kamu sudah paham dua ide yang diam-diam mengatur cara variabel JavaScript berperilaku:
- Scope adalah tempat sebuah variabel terlihat. Ada global, function, dan block scope.
letdanconstmenghormati batas blok;varcuma menghormati scope fungsi dan bocor keluar dari blok — jadi pakaiconstsebagai default, pakailetkalau perlu menugaskan ulang, dan tinggalkanvar. - JavaScript menemukan variabel dengan menyusuri scope chain — dari scope saat ini ke luar menuju global, mengambil yang cocok pertama. Pencariannya nggak pernah ke dalam atau ke samping.
- Closure adalah fungsi yang mengingat variabel dari scope tempat ia diciptakan, bahkan setelah scope itu selesai. Dia menangkap variabelnya sendiri, bukan potret dari nilainya.
- Closure memberimu state pribadi (pola counter), menggerakkan event handler dan pabrik fungsi, dan sebenarnya sudah berjalan di mana-mana di kodemu.
- Selalu deklarasikan variabelmu supaya nggak tanpa sengaja bocor jadi global; strict mode membantu menangkap kesalahan ini.
Scope dan closure adalah jenis topik yang, begitu “klik”, langsung membuat selusin perilaku lain tiba-tiba masuk akal. Pasangan yang pas untuk topik ini adalah keyword this — satu lagi aturan soal konteks yang bikin orang bingung karena alasan serupa, dan jadi bahasan artikel berikutnya. Dengan scope dan closure sudah di tangan, kamu siap untuk itu.